From df054ac7f6bb0551b62cc2d3a5f1ced80eb600db Mon Sep 17 00:00:00 2001 From: forehalo Date: Thu, 19 Oct 2023 10:06:34 +0800 Subject: [PATCH 01/27] feat(core): payment backend --- packages/backend/server/.env.example | 4 +- .../20231018074747_payment/migration.sql | 68 +++ packages/backend/server/package.json | 2 + packages/backend/server/schema.prisma | 65 ++ packages/backend/server/src/config/def.ts | 9 + packages/backend/server/src/config/default.ts | 11 + packages/backend/server/src/index.ts | 1 + packages/backend/server/src/modules/index.ts | 14 +- .../server/src/modules/payment/index.ts | 17 + .../server/src/modules/payment/resolver.ts | 246 ++++++++ .../server/src/modules/payment/service.ts | 576 ++++++++++++++++++ .../server/src/modules/payment/stripe.ts | 18 + .../server/src/modules/payment/webhook.ts | 75 +++ .../server/src/modules/users/resolver.ts | 10 +- packages/backend/server/src/schema.gql | 78 ++- packages/backend/server/tests/user.e2e.ts | 2 +- packages/frontend/graphql/src/schema.ts | 33 +- yarn.lock | 39 ++ 18 files changed, 1260 insertions(+), 8 deletions(-) create mode 100644 packages/backend/server/migrations/20231018074747_payment/migration.sql create mode 100644 packages/backend/server/src/modules/payment/index.ts create mode 100644 packages/backend/server/src/modules/payment/resolver.ts create mode 100644 packages/backend/server/src/modules/payment/service.ts create mode 100644 packages/backend/server/src/modules/payment/stripe.ts create mode 100644 packages/backend/server/src/modules/payment/webhook.ts diff --git a/packages/backend/server/.env.example b/packages/backend/server/.env.example index 55d11ef5fb..749873dc40 100644 --- a/packages/backend/server/.env.example +++ b/packages/backend/server/.env.example @@ -3,4 +3,6 @@ NEXTAUTH_URL="http://localhost:8080" OAUTH_EMAIL_SENDER="noreply@toeverything.info" OAUTH_EMAIL_LOGIN="" OAUTH_EMAIL_PASSWORD="" -ENABLE_LOCAL_EMAIL="true" \ No newline at end of file +ENABLE_LOCAL_EMAIL="true" +STRIPE_API_KEY= +STRIPE_WEBHOOK_KEY= diff --git a/packages/backend/server/migrations/20231018074747_payment/migration.sql b/packages/backend/server/migrations/20231018074747_payment/migration.sql new file mode 100644 index 0000000000..abd017259d --- /dev/null +++ b/packages/backend/server/migrations/20231018074747_payment/migration.sql @@ -0,0 +1,68 @@ +-- CreateTable +CREATE TABLE "user_stripe_customers" ( + "user_id" VARCHAR NOT NULL, + "stripe_customer_id" VARCHAR NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_stripe_customers_pkey" PRIMARY KEY ("user_id") +); + +-- CreateTable +CREATE TABLE "user_subscriptions" ( + "id" SERIAL NOT NULL, + "user_id" VARCHAR(36) NOT NULL, + "plan" VARCHAR(20) NOT NULL, + "recurring" VARCHAR(20) NOT NULL, + "stripe_subscription_id" TEXT NOT NULL, + "status" VARCHAR(20) NOT NULL, + "start" TIMESTAMPTZ(6) NOT NULL, + "end" TIMESTAMPTZ(6) NOT NULL, + "next_bill_at" TIMESTAMPTZ(6), + "canceled_at" TIMESTAMPTZ(6), + "trial_start" TIMESTAMPTZ(6), + "trial_end" TIMESTAMPTZ(6), + "stripe_schedule_id" VARCHAR, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6) NOT NULL, + + CONSTRAINT "user_subscriptions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_invoices" ( + "id" SERIAL NOT NULL, + "user_id" VARCHAR(36) NOT NULL, + "stripe_invoice_id" TEXT NOT NULL, + "currency" VARCHAR(3) NOT NULL, + "amount" INTEGER NOT NULL, + "status" VARCHAR(20) NOT NULL, + "plan" VARCHAR(20) NOT NULL, + "recurring" VARCHAR(20) NOT NULL, + "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6) NOT NULL, + "reason" VARCHAR NOT NULL, + "last_payment_error" TEXT, + + CONSTRAINT "user_invoices_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "user_stripe_customers_stripe_customer_id_key" ON "user_stripe_customers"("stripe_customer_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_subscriptions_user_id_key" ON "user_subscriptions"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_subscriptions_stripe_subscription_id_key" ON "user_subscriptions"("stripe_subscription_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_invoices_stripe_invoice_id_key" ON "user_invoices"("stripe_invoice_id"); + +-- AddForeignKey +ALTER TABLE "user_stripe_customers" ADD CONSTRAINT "user_stripe_customers_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_subscriptions" ADD CONSTRAINT "user_subscriptions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_invoices" ADD CONSTRAINT "user_invoices_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index 2567bd6c9d..2571db2fb4 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -25,6 +25,7 @@ "@nestjs/apollo": "^12.0.9", "@nestjs/common": "^10.2.7", "@nestjs/core": "^10.2.7", + "@nestjs/event-emitter": "^2.0.2", "@nestjs/graphql": "^12.0.9", "@nestjs/platform-express": "^10.2.7", "@nestjs/platform-socket.io": "^10.2.7", @@ -71,6 +72,7 @@ "rxjs": "^7.8.1", "semver": "^7.5.4", "socket.io": "^4.7.2", + "stripe": "^13.6.0", "ws": "^8.14.2", "yjs": "^13.6.8" }, diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 70c6edf834..bea9b7f8d5 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -49,6 +49,9 @@ model User { /// Not available if user signed up through OAuth providers password String? @db.VarChar features UserFeatureGates[] + customer UserStripeCustomer? + subscription UserSubscription? + invoices UserInvoice[] @@map("users") } @@ -164,3 +167,65 @@ model NewFeaturesWaitingList { @@map("new_features_waiting_list") } + +model UserStripeCustomer { + userId String @id @map("user_id") @db.VarChar + stripeCustomerId String @unique @map("stripe_customer_id") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("user_stripe_customers") +} + +model UserSubscription { + id Int @id @default(autoincrement()) @db.Integer + userId String @unique @map("user_id") @db.VarChar(36) + plan String @db.VarChar(20) + // yearly/monthly + recurring String @db.VarChar(20) + // subscription.id + stripeSubscriptionId String @unique @map("stripe_subscription_id") + // subscription.status, active/past_due/canceled/unpaid... + status String @db.VarChar(20) + // subscription.current_period_start + start DateTime @map("start") @db.Timestamptz(6) + // subscription.current_period_end + end DateTime @map("end") @db.Timestamptz(6) + // subscription.billing_cycle_anchor + nextBillAt DateTime? @map("next_bill_at") @db.Timestamptz(6) + // subscription.canceled_at + canceledAt DateTime? @map("canceled_at") @db.Timestamptz(6) + // subscription.trial_start + trialStart DateTime? @map("trial_start") @db.Timestamptz(6) + // subscription.trial_end + trialEnd DateTime? @map("trial_end") @db.Timestamptz(6) + stripeScheduleId String? @map("stripe_schedule_id") @db.VarChar + + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("user_subscriptions") +} + +model UserInvoice { + id Int @id @default(autoincrement()) @db.Integer + userId String @map("user_id") @db.VarChar(36) + stripeInvoiceId String @unique @map("stripe_invoice_id") + currency String @db.VarChar(3) + // CNY 12.50 stored as 1250 + amount Int @db.Integer + status String @db.VarChar(20) + plan String @db.VarChar(20) + recurring String @db.VarChar(20) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6) + // billing reason + reason String @db.VarChar + lastPaymentError String? @map("last_payment_error") @db.Text + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("user_invoices") +} diff --git a/packages/backend/server/src/config/def.ts b/packages/backend/server/src/config/def.ts index 345b3942e1..b7d1493d4a 100644 --- a/packages/backend/server/src/config/def.ts +++ b/packages/backend/server/src/config/def.ts @@ -363,4 +363,13 @@ export interface AFFiNEConfig { experimentalMergeWithJwstCodec: boolean; }; }; + + payment: { + stripe: { + keys: { + APIKey: string; + webhookKey: string; + }; + } & import('stripe').Stripe.StripeConfig; + }; } diff --git a/packages/backend/server/src/config/default.ts b/packages/backend/server/src/config/default.ts index b23b915f79..ae46469847 100644 --- a/packages/backend/server/src/config/default.ts +++ b/packages/backend/server/src/config/default.ts @@ -89,6 +89,8 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { 'boolean', ], ENABLE_LOCAL_EMAIL: ['auth.localEmail', 'boolean'], + STRIPE_API_KEY: 'payment.stripe.keys.APIKey', + STRIPE_WEBHOOK_KEY: 'payment.stripe.keys.webhookKey', } satisfies AFFiNEConfig['ENV_MAP'], affineEnv: 'dev', get affine() { @@ -207,6 +209,15 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { experimentalMergeWithJwstCodec: false, }, }, + payment: { + stripe: { + keys: { + APIKey: '', + webhookKey: '', + }, + apiVersion: '2023-08-16', + }, + }, } satisfies AFFiNEConfig; applyEnvToConfig(defaultConfig); diff --git a/packages/backend/server/src/index.ts b/packages/backend/server/src/index.ts index 4b83dbd446..446b82c60d 100644 --- a/packages/backend/server/src/index.ts +++ b/packages/backend/server/src/index.ts @@ -59,6 +59,7 @@ if (NODE_ENV === 'production') { const app = await NestFactory.create(AppModule, { cors: true, + rawBody: true, bodyParser: true, logger: NODE_ENV !== 'production' || AFFINE_ENV !== 'production' diff --git a/packages/backend/server/src/modules/index.ts b/packages/backend/server/src/modules/index.ts index c11e3428b1..600c5154b2 100644 --- a/packages/backend/server/src/modules/index.ts +++ b/packages/backend/server/src/modules/index.ts @@ -1,8 +1,10 @@ import { DynamicModule, Type } from '@nestjs/common'; +import { EventEmitterModule } from '@nestjs/event-emitter'; import { GqlModule } from '../graphql.module'; import { AuthModule } from './auth'; import { DocModule } from './doc'; +import { PaymentModule } from './payment'; import { SyncModule } from './sync'; import { UsersModule } from './users'; import { WorkspaceModule } from './workspaces'; @@ -17,22 +19,30 @@ switch (SERVER_FLAVOR) { break; case 'graphql': BusinessModules.push( + EventEmitterModule.forRoot({ + global: true, + }), GqlModule, WorkspaceModule, UsersModule, AuthModule, - DocModule.forRoot() + DocModule.forRoot(), + PaymentModule ); break; case 'allinone': default: BusinessModules.push( + EventEmitterModule.forRoot({ + global: true, + }), GqlModule, WorkspaceModule, UsersModule, AuthModule, SyncModule, - DocModule.forRoot() + DocModule.forRoot(), + PaymentModule ); break; } diff --git a/packages/backend/server/src/modules/payment/index.ts b/packages/backend/server/src/modules/payment/index.ts new file mode 100644 index 0000000000..1a51678848 --- /dev/null +++ b/packages/backend/server/src/modules/payment/index.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; + +import { SubscriptionResolver, UserSubscriptionResolver } from './resolver'; +import { SubscriptionService } from './service'; +import { StripeProvider } from './stripe'; +import { StripeWebhook } from './webhook'; + +@Module({ + providers: [ + StripeProvider, + SubscriptionService, + SubscriptionResolver, + UserSubscriptionResolver, + ], + controllers: [StripeWebhook], +}) +export class PaymentModule {} diff --git a/packages/backend/server/src/modules/payment/resolver.ts b/packages/backend/server/src/modules/payment/resolver.ts new file mode 100644 index 0000000000..e5e1a77a3c --- /dev/null +++ b/packages/backend/server/src/modules/payment/resolver.ts @@ -0,0 +1,246 @@ +import { + BadGatewayException, + ForbiddenException, + InternalServerErrorException, +} from '@nestjs/common'; +import { + Args, + Field, + Int, + Mutation, + ObjectType, + Parent, + Query, + registerEnumType, + ResolveField, + Resolver, +} from '@nestjs/graphql'; +import type { User, UserInvoice, UserSubscription } from '@prisma/client'; + +import { Config } from '../../config'; +import { PrismaService } from '../../prisma'; +import { Auth, CurrentUser, Public } from '../auth'; +import { UserType } from '../users'; +import { + InvoiceStatus, + SubscriptionPlan, + SubscriptionRecurring, + SubscriptionService, + SubscriptionStatus, +} from './service'; + +registerEnumType(SubscriptionStatus, { name: 'SubscriptionStatus' }); +registerEnumType(SubscriptionRecurring, { name: 'SubscriptionRecurring' }); +registerEnumType(SubscriptionPlan, { name: 'SubscriptionPlan' }); +registerEnumType(InvoiceStatus, { name: 'InvoiceStatus' }); + +@ObjectType() +class SubscriptionPrice { + @Field(() => String) + type!: 'fixed'; + + @Field(() => SubscriptionPlan) + plan!: SubscriptionPlan; + + @Field() + currency!: string; + + @Field() + amount!: number; + + @Field() + yearlyAmount!: number; +} + +@ObjectType('UserSubscription') +class UserSubscriptionType implements Partial { + @Field({ name: 'id' }) + stripeSubscriptionId!: string; + + @Field(() => SubscriptionPlan) + plan!: SubscriptionPlan; + + @Field(() => SubscriptionRecurring) + recurring!: SubscriptionRecurring; + + @Field(() => SubscriptionStatus) + status!: SubscriptionStatus; + + @Field(() => Date) + start!: Date; + + @Field(() => Date) + end!: Date; + + @Field(() => Date, { nullable: true }) + trialStart?: Date | null; + + @Field(() => Date, { nullable: true }) + trialEnd?: Date | null; + + @Field(() => Date, { nullable: true }) + nextBillAt?: Date | null; + + @Field(() => Date, { nullable: true }) + canceledAt?: Date | null; + + @Field(() => Date) + createdAt!: Date; + + @Field(() => Date) + updatedAt!: Date; +} + +@ObjectType('UserInvoice') +class UserInvoiceType implements Partial { + @Field({ name: 'id' }) + stripeInvoiceId!: string; + + @Field(() => SubscriptionPlan) + plan!: SubscriptionPlan; + + @Field(() => SubscriptionRecurring) + recurring!: SubscriptionRecurring; + + @Field() + currency!: string; + + @Field() + amount!: number; + + @Field(() => InvoiceStatus) + status!: InvoiceStatus; + + @Field() + reason!: string; + + @Field(() => String, { nullable: true }) + lastPaymentError?: string | null; + + @Field(() => Date) + createdAt!: Date; + + @Field(() => Date) + updatedAt!: Date; +} + +@Auth() +@Resolver(() => UserSubscriptionType) +export class SubscriptionResolver { + constructor( + private readonly service: SubscriptionService, + private readonly config: Config + ) {} + + @Public() + @Query(() => [SubscriptionPrice]) + async prices(): Promise { + const prices = await this.service.listPrices(); + + const yearly = prices.data.find( + price => price.lookup_key === SubscriptionRecurring.Yearly + ); + const monthly = prices.data.find( + price => price.lookup_key === SubscriptionRecurring.Monthly + ); + + if (!yearly || !monthly) { + throw new BadGatewayException('The prices are not configured correctly'); + } + + return [ + { + type: 'fixed', + plan: SubscriptionPlan.Pro, + currency: monthly.currency, + amount: monthly.unit_amount ?? 0, + yearlyAmount: yearly.unit_amount ?? 0, + }, + ]; + } + + @Mutation(() => String, { + description: 'Create a subscription checkout link of stripe', + }) + async checkout( + @CurrentUser() user: User, + @Args({ name: 'recurring', type: () => SubscriptionRecurring }) + recurring: SubscriptionRecurring + ) { + const session = await this.service.createCheckoutSession({ + user, + recurring, + // TODO: replace with frontend url + redirectUrl: `${this.config.baseUrl}/api/stripe/success`, + }); + + if (!session.url) { + throw new InternalServerErrorException( + 'Failed to create checkout session' + ); + } + + return session.url; + } + + @Mutation(() => UserSubscriptionType) + async cancelSubscription(@CurrentUser() user: User) { + return this.service.cancelSubscription(user.id); + } + + @Mutation(() => UserSubscriptionType) + async resumeSubscription(@CurrentUser() user: User) { + return this.service.resumeCanceledSubscriptin(user.id); + } + + @Mutation(() => UserSubscriptionType) + async updateSubscriptionRecurring( + @CurrentUser() user: User, + @Args({ name: 'recurring', type: () => SubscriptionRecurring }) + recurring: SubscriptionRecurring + ) { + return this.service.updateSubscriptionRecurring(user.id, recurring); + } +} + +@Resolver(() => UserType) +export class UserSubscriptionResolver { + constructor(private readonly db: PrismaService) {} + + @ResolveField(() => UserSubscriptionType, { nullable: true }) + async subscription(@CurrentUser() me: User, @Parent() user: User) { + if (me.id !== user.id) { + throw new ForbiddenException(); + } + + return this.db.userSubscription.findUnique({ + where: { + userId: user.id, + }, + }); + } + + @ResolveField(() => [UserInvoiceType]) + async invoices( + @CurrentUser() me: User, + @Parent() user: User, + @Args('take', { type: () => Int, nullable: true, defaultValue: 8 }) + take: number, + @Args('skip', { type: () => Int, nullable: true }) skip?: number + ) { + if (me.id !== user.id) { + throw new ForbiddenException(); + } + + return this.db.userInvoice.findMany({ + where: { + userId: user.id, + }, + take, + skip, + orderBy: { + id: 'desc', + }, + }); + } +} diff --git a/packages/backend/server/src/modules/payment/service.ts b/packages/backend/server/src/modules/payment/service.ts new file mode 100644 index 0000000000..8f9771b1bd --- /dev/null +++ b/packages/backend/server/src/modules/payment/service.ts @@ -0,0 +1,576 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent as RawOnEvent } from '@nestjs/event-emitter'; +import type { + Prisma, + User, + UserInvoice, + UserStripeCustomer, + UserSubscription, +} from '@prisma/client'; +import Stripe from 'stripe'; + +import { Config } from '../../config'; +import { PrismaService } from '../../prisma'; + +const OnEvent = ( + event: Stripe.Event.Type, + opts?: Parameters[1] +) => RawOnEvent(event, opts); + +// also used as lookup key for stripe prices +export enum SubscriptionRecurring { + Monthly = 'monthly', + Yearly = 'yearly', +} + +export enum SubscriptionPlan { + Free = 'free', + Pro = 'pro', + Team = 'team', + Enterprise = 'enterprise', +} + +// see https://stripe.com/docs/api/subscriptions/object#subscription_object-status +export enum SubscriptionStatus { + Active = 'active', + PastDue = 'past_due', + Unpaid = 'unpaid', + Canceled = 'canceled', + Incomplete = 'incomplete', + Paused = 'paused', + IncompleteExpired = 'incomplete_expired', + Trialing = 'trialing', +} + +export enum InvoiceStatus { + Draft = 'draft', + Open = 'open', + Void = 'void', + Paid = 'paid', + Uncollectible = 'uncollectible', +} + +@Injectable() +export class SubscriptionService { + private readonly paymentConfig: Config['payment']; + private readonly logger = new Logger(SubscriptionService.name); + + constructor( + config: Config, + private readonly stripe: Stripe, + private readonly db: PrismaService + ) { + this.paymentConfig = config.payment; + + if ( + !this.paymentConfig.stripe.keys.APIKey || + !this.paymentConfig.stripe.keys.webhookKey /* default empty string */ + ) { + this.logger.warn('Stripe API key not set, Stripe will be disabled'); + this.logger.warn('Set STRIPE_API_KEY to enable Stripe'); + } + } + + async listPrices() { + return this.stripe.prices.list({ + lookup_keys: Object.values(SubscriptionRecurring), + }); + } + + async createCheckoutSession({ + user, + recurring, + redirectUrl, + }: { + user: User; + recurring: SubscriptionRecurring; + redirectUrl: string; + }) { + const currentSubscription = await this.db.userSubscription.findUnique({ + where: { + userId: user.id, + }, + }); + + if (currentSubscription && currentSubscription.end < new Date()) { + throw new Error('User already has a subscription'); + } + + const prices = await this.stripe.prices.list({ + lookup_keys: [recurring], + }); + + if (!prices.data.length) { + throw new Error(`Unknown subscription recurring: ${recurring}`); + } + + const customer = await this.getOrCreateCustomer(user); + return await this.stripe.checkout.sessions.create({ + line_items: [ + { + price: prices.data[0].id, + quantity: 1, + }, + ], + allow_promotion_codes: true, + tax_id_collection: { + enabled: true, + }, + mode: 'subscription', + success_url: redirectUrl, + customer: customer.stripeCustomerId, + customer_update: { + address: 'auto', + name: 'auto', + }, + }); + } + + async cancelSubscription(userId: string): Promise { + const user = await this.db.user.findUnique({ + where: { + id: userId, + }, + include: { + subscription: true, + }, + }); + + if (!user?.subscription) { + throw new Error('User has no subscription'); + } + + if (user.subscription.canceledAt) { + throw new Error('User subscription has already been canceled '); + } + + // should release the schedule first + if (user.subscription.stripeScheduleId) { + await this.stripe.subscriptionSchedules.release( + user.subscription.stripeScheduleId + ); + } + + // let customer contact support if they want to cancel immediately + // see https://stripe.com/docs/billing/subscriptions/cancel + const subscription = await this.stripe.subscriptions.update( + user.subscription.stripeSubscriptionId, + { + cancel_at_period_end: true, + } + ); + + return await this.saveSubscription(user, subscription); + } + + async resumeCanceledSubscriptin(userId: string): Promise { + const user = await this.db.user.findUnique({ + where: { + id: userId, + }, + include: { + subscription: true, + }, + }); + + if (!user?.subscription) { + throw new Error('User has no subscription'); + } + + if (!user.subscription.canceledAt) { + throw new Error('User subscription is not canceled'); + } + + if (user.subscription.end < new Date()) { + throw new Error( + 'User subscription has already expired, please checkout again.' + ); + } + + const subscription = await this.stripe.subscriptions.update( + user.subscription.stripeSubscriptionId, + { + cancel_at_period_end: false, + } + ); + + return await this.saveSubscription(user, subscription); + } + + async updateSubscriptionRecurring( + userId: string, + recurring: string + ): Promise { + const user = await this.db.user.findUnique({ + where: { + id: userId, + }, + include: { + subscription: true, + }, + }); + + if (!user?.subscription) { + throw new Error('User has no subscription'); + } + + if (user.subscription.recurring === recurring) { + throw new Error('User has already subscribed to this plan'); + } + + const prices = await this.stripe.prices.list({ + lookup_keys: [recurring], + }); + + if (!prices.data.length) { + throw new Error(`Unknown subscription recurring: ${recurring}`); + } + + const newPrice = prices.data[0]; + + // a schedule existing + if (user.subscription.stripeScheduleId) { + const schedule = await this.stripe.subscriptionSchedules.retrieve( + user.subscription.stripeScheduleId + ); + + // a scheduled subscription's old price equals the change + if ( + schedule.phases[0] && + (schedule.phases[0].items[0].price as string) === newPrice.id + ) { + await this.stripe.subscriptionSchedules.release( + user.subscription.stripeScheduleId + ); + + return await this.db.userSubscription.update({ + where: { + id: user.subscription.id, + }, + data: { + recurring, + }, + }); + } else { + throw new Error( + 'Unexpected subscription scheduled, please contact the supporters' + ); + } + } else { + const schedule = await this.stripe.subscriptionSchedules.create({ + from_subscription: user.subscription.stripeSubscriptionId, + }); + + await this.stripe.subscriptionSchedules.update(schedule.id, { + phases: [ + { + items: [ + { + price: schedule.phases[0].items[0].price as string, + quantity: 1, + }, + ], + start_date: schedule.phases[0].start_date, + end_date: schedule.phases[0].end_date, + }, + { + items: [ + { + price: newPrice.id, + quantity: 1, + }, + ], + }, + ], + }); + + return await this.db.userSubscription.update({ + where: { + id: user.subscription.id, + }, + data: { + recurring, + stripeScheduleId: schedule.id, + }, + }); + } + } + + @OnEvent('customer.subscription.created') + @OnEvent('customer.subscription.updated') + async onSubscriptionChanges(subscription: Stripe.Subscription) { + const user = await this.retrieveUserFromCustomer( + subscription.customer as string + ); + + await this.saveSubscription(user, subscription); + } + + @OnEvent('customer.subscription.deleted') + async onSubscriptionDeleted(subscription: Stripe.Subscription) { + const user = await this.retrieveUserFromCustomer( + subscription.customer as string + ); + + await this.db.userSubscription.deleteMany({ + where: { + stripeSubscriptionId: subscription.id, + userId: user.id, + }, + }); + } + + @OnEvent('invoice.created') + async onInvoiceCreated(invoice: Stripe.Invoice) { + await this.saveInvoice(invoice); + } + + @OnEvent('invoice.paid') + async onInvoicePaid(invoice: Stripe.Invoice) { + await this.saveInvoice(invoice); + } + + @OnEvent('invoice.finalization_failed') + async onInvoiceFinalizeFailed(invoice: Stripe.Invoice) { + await this.saveInvoice(invoice); + } + + @OnEvent('invoice.payment_failed') + async onInvoicePaymentFailed(invoice: Stripe.Invoice) { + await this.saveInvoice(invoice); + } + + private async saveSubscription( + user: User, + subscription: Stripe.Subscription + ): Promise { + // get next bill date from upcoming invoice + // see https://stripe.com/docs/api/invoices/upcoming + let nextBillAt: Date | null = null; + if ( + (subscription.status === SubscriptionStatus.Active || + subscription.status === SubscriptionStatus.Trialing) && + !subscription.canceled_at + ) { + try { + const nextInvoice = await this.stripe.invoices.retrieveUpcoming({ + customer: subscription.customer as string, + subscription: subscription.id, + }); + + nextBillAt = new Date(nextInvoice.created * 1000); + } catch (e) { + // no upcoming invoice + // safe to ignore + } + } + + const price = subscription.items.data[0].price; + + const commonData = { + start: new Date(subscription.current_period_start * 1000), + end: new Date(subscription.current_period_end * 1000), + trialStart: subscription.trial_start + ? new Date(subscription.trial_start * 1000) + : null, + trialEnd: subscription.trial_end + ? new Date(subscription.trial_end * 1000) + : null, + nextBillAt, + canceledAt: subscription.canceled_at + ? new Date(subscription.canceled_at * 1000) + : null, + stripeSubscriptionId: subscription.id, + recurring: price.lookup_key ?? price.id, + // TODO: dynamic plans + plan: SubscriptionPlan.Pro, + status: subscription.status, + stripeScheduleId: subscription.schedule as string | null, + }; + + const currentSubscription = await this.db.userSubscription.findUnique({ + where: { + userId: user.id, + }, + }); + + if (currentSubscription) { + const update: Prisma.UserSubscriptionUpdateInput = { + ...commonData, + }; + + // a schedule exists, update the recurring to scheduled one + if (update.stripeScheduleId) { + delete update.recurring; + } + + return await this.db.userSubscription.update({ + where: { + id: currentSubscription.id, + }, + data: update, + }); + } else { + return await this.db.userSubscription.create({ + data: { + userId: user.id, + ...commonData, + }, + }); + } + } + + private async getOrCreateCustomer(user: User): Promise { + const customer = await this.db.userStripeCustomer.findUnique({ + where: { + userId: user.id, + }, + }); + + if (customer) { + return customer; + } + + const stripeCustomersList = await this.stripe.customers.list({ + email: user.email, + limit: 1, + }); + + let stripeCustomer: Stripe.Customer | undefined; + if (stripeCustomersList.data.length) { + stripeCustomer = stripeCustomersList.data[0]; + } else { + stripeCustomer = await this.stripe.customers.create({ + email: user.email, + }); + } + + return await this.db.userStripeCustomer.create({ + data: { + userId: user.id, + stripeCustomerId: stripeCustomer.id, + }, + }); + } + + private async retrieveUserFromCustomer(customerId: string) { + const customer = await this.db.userStripeCustomer.findUnique({ + where: { + stripeCustomerId: customerId, + }, + include: { + user: true, + }, + }); + + if (customer?.user) { + return customer.user; + } + + // customer may not saved is db, check it with stripe + const stripeCustomer = await this.stripe.customers.retrieve(customerId); + + if (stripeCustomer.deleted) { + throw new Error('Unexpected subscription created with deleted customer'); + } + + if (!stripeCustomer.email) { + throw new Error('Unexpected subscription created with no email customer'); + } + + const user = await this.db.user.findUnique({ + where: { + email: stripeCustomer.email, + }, + }); + + if (!user) { + throw new Error( + `Unexpected subscription created with unknown customer ${stripeCustomer.email}` + ); + } + + await this.db.userStripeCustomer.create({ + data: { + userId: user.id, + stripeCustomerId: stripeCustomer.id, + }, + }); + + return user; + } + + private async saveInvoice(stripeInvoice: Stripe.Invoice) { + if (!stripeInvoice.customer) { + throw new Error('Unexpected invoice with no customer'); + } + + const user = await this.retrieveUserFromCustomer( + stripeInvoice.customer as string + ); + + const invoice = await this.db.userInvoice.findUnique({ + where: { + stripeInvoiceId: stripeInvoice.id, + }, + }); + + const data: Partial = { + currency: stripeInvoice.currency, + amount: stripeInvoice.total, + status: stripeInvoice.status ?? InvoiceStatus.Void, + }; + + // handle payment error + if (stripeInvoice.attempt_count > 1) { + const paymentIntent = await this.stripe.paymentIntents.retrieve( + stripeInvoice.payment_intent as string + ); + + if (paymentIntent.last_payment_error) { + if (paymentIntent.last_payment_error.type === 'card_error') { + data.lastPaymentError = + paymentIntent.last_payment_error.message ?? 'Failed to pay'; + } else { + data.lastPaymentError = 'Internal Payment error'; + } + } + } else if (stripeInvoice.last_finalization_error) { + if (stripeInvoice.last_finalization_error.type === 'card_error') { + data.lastPaymentError = + stripeInvoice.last_finalization_error.message ?? + 'Failed to finalize invoice'; + } else { + data.lastPaymentError = 'Internal Payment error'; + } + } + + // update invoice + if (invoice) { + await this.db.userInvoice.update({ + where: { + stripeInvoiceId: stripeInvoice.id, + }, + data, + }); + } else { + // create invoice + const price = stripeInvoice.lines.data[0].price; + + if (!price || price.type !== 'recurring') { + throw new Error('Unexpected invoice with no recurring price'); + } + + await this.db.userInvoice.create({ + data: { + userId: user.id, + stripeInvoiceId: stripeInvoice.id, + plan: SubscriptionPlan.Pro, + recurring: price.lookup_key ?? price.id, + reason: stripeInvoice.billing_reason ?? 'contact support', + ...(data as any), + }, + }); + } + } +} diff --git a/packages/backend/server/src/modules/payment/stripe.ts b/packages/backend/server/src/modules/payment/stripe.ts new file mode 100644 index 0000000000..4538471121 --- /dev/null +++ b/packages/backend/server/src/modules/payment/stripe.ts @@ -0,0 +1,18 @@ +import { FactoryProvider } from '@nestjs/common'; +import { omit } from 'lodash-es'; +import Stripe from 'stripe'; + +import { Config } from '../../config'; + +export const StripeProvider: FactoryProvider = { + provide: Stripe, + useFactory: (config: Config) => { + const stripeConfig = config.payment.stripe; + + return new Stripe( + stripeConfig.keys.APIKey, + omit(config.payment.stripe, 'keys', 'prices') + ); + }, + inject: [Config], +}; diff --git a/packages/backend/server/src/modules/payment/webhook.ts b/packages/backend/server/src/modules/payment/webhook.ts new file mode 100644 index 0000000000..785ea97832 --- /dev/null +++ b/packages/backend/server/src/modules/payment/webhook.ts @@ -0,0 +1,75 @@ +import type { RawBodyRequest } from '@nestjs/common'; +import { + Controller, + Get, + Logger, + NotAcceptableException, + Post, + Req, +} from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import type { User } from '@prisma/client'; +import type { Request } from 'express'; +import Stripe from 'stripe'; + +import { Config } from '../../config'; +import { PrismaService } from '../../prisma'; +import { Auth, CurrentUser } from '../auth'; + +@Controller('/api/stripe') +export class StripeWebhook { + private readonly config: Config['payment']; + private readonly logger = new Logger(StripeWebhook.name); + + constructor( + config: Config, + private readonly stripe: Stripe, + private readonly event: EventEmitter2, + private readonly db: PrismaService + ) { + this.config = config.payment; + } + + // just for test + @Auth() + @Get('/success') + async handleSuccess(@CurrentUser() user: User) { + return this.db.userSubscription.findUnique({ + where: { + userId: user.id, + }, + }); + } + + @Post('/webhook') + async handleWebhook(@Req() req: RawBodyRequest) { + // Check if webhook signing is configured. + if (!this.config.stripe.keys.webhookKey) { + this.logger.error( + 'Stripe Webhook key is not set, but a webhook was received.' + ); + throw new NotAcceptableException(); + } + + // Retrieve the event by verifying the signature using the raw body and secret. + const signature = req.headers['stripe-signature']; + try { + const event = this.stripe.webhooks.constructEvent( + req.rawBody ?? '', + signature ?? '', + this.config.stripe.keys.webhookKey + ); + + this.logger.debug( + `[${event.id}] Stripe Webhook {${event.type}} received.` + ); + + // handle duplicated events? + // see https://stripe.com/docs/webhooks#handle-duplicate-events + await this.event.emitAsync(event.type, event.data.object); + } catch (err) { + this.logger.error('Stripe Webhook error', err); + throw new NotAcceptableException(); + } + } +} diff --git a/packages/backend/server/src/modules/users/resolver.ts b/packages/backend/server/src/modules/users/resolver.ts index b735a4c1f1..cc3a529f44 100644 --- a/packages/backend/server/src/modules/users/resolver.ts +++ b/packages/backend/server/src/modules/users/resolver.ts @@ -21,7 +21,7 @@ import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import { PrismaService } from '../../prisma/service'; import { CloudThrottlerGuard, Throttle } from '../../throttler'; import type { FileUpload } from '../../types'; -import { Auth, CurrentUser, Public } from '../auth/guard'; +import { Auth, CurrentUser, Public, Publicable } from '../auth/guard'; import { StorageService } from '../storage/storage.service'; import { NewFeaturesKind } from './types'; import { UsersService } from './users'; @@ -97,11 +97,17 @@ export class UserResolver { ttl: 60, }, }) + @Publicable() @Query(() => UserType, { name: 'currentUser', description: 'Get current user', + nullable: true, }) - async currentUser(@CurrentUser() user: UserType) { + async currentUser(@CurrentUser() user?: UserType) { + if (!user) { + return null; + } + const storedUser = await this.users.findUserById(user.id); if (!storedUser) { throw new BadRequestException(`User ${user.id} not found in db`); diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 63d8248ede..6be3ba621e 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -23,6 +23,8 @@ type UserType { """User password has been set""" hasPassword: Boolean token: TokenType! + subscription: UserSubscription + invoices(take: Int = 8, skip: Int): [UserInvoice!]! } """ @@ -55,6 +57,73 @@ type TokenType { sessionToken: String } +type SubscriptionPrice { + type: String! + plan: SubscriptionPlan! + currency: String! + amount: Int! + yearlyAmount: Int! +} + +enum SubscriptionPlan { + Free + Pro + Team + Enterprise +} + +type UserSubscription { + id: String! + plan: SubscriptionPlan! + recurring: SubscriptionRecurring! + status: SubscriptionStatus! + start: DateTime! + end: DateTime! + trialStart: DateTime + trialEnd: DateTime + nextBillAt: DateTime + canceledAt: DateTime + createdAt: DateTime! + updatedAt: DateTime! +} + +enum SubscriptionRecurring { + Monthly + Yearly +} + +enum SubscriptionStatus { + Active + PastDue + Unpaid + Canceled + Incomplete + Paused + IncompleteExpired + Trialing +} + +type UserInvoice { + id: String! + plan: SubscriptionPlan! + recurring: SubscriptionRecurring! + currency: String! + amount: Int! + status: InvoiceStatus! + reason: String! + lastPaymentError: String + createdAt: DateTime! + updatedAt: DateTime! +} + +enum InvoiceStatus { + Draft + Open + Void + Paid + Uncollectible +} + type InviteUserType { """User name""" name: String @@ -166,10 +235,11 @@ type Query { checkBlobSize(workspaceId: String!, size: Float!): WorkspaceBlobSizes! """Get current user""" - currentUser: UserType! + currentUser: UserType """Get user by email""" user(email: String!): UserType + prices: [SubscriptionPrice!]! } type Mutation { @@ -205,6 +275,12 @@ type Mutation { removeAvatar: RemoveAvatar! deleteAccount: DeleteAccount! addToNewFeaturesWaitingList(type: NewFeaturesKind!, email: String!): AddToNewFeaturesWaitingList! + + """Create a subscription checkout link of stripe""" + checkout(recurring: SubscriptionRecurring!): String! + cancelSubscription: UserSubscription! + resumeSubscription: UserSubscription! + updateSubscriptionRecurring(recurring: SubscriptionRecurring!): UserSubscription! } """The `Upload` scalar type represents a file upload.""" diff --git a/packages/backend/server/tests/user.e2e.ts b/packages/backend/server/tests/user.e2e.ts index da06bc4322..065925af21 100644 --- a/packages/backend/server/tests/user.e2e.ts +++ b/packages/backend/server/tests/user.e2e.ts @@ -67,6 +67,6 @@ test('should be able to delete user', async t => { `, }) .expect(200); - await t.throwsAsync(() => currentUser(app, user.token.token)); + t.is(await currentUser(app, user.token.token), null); t.pass(); }); diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 1a47becf06..3a84e42fe4 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -32,6 +32,14 @@ export interface Scalars { Upload: { input: File; output: File }; } +export enum InvoiceStatus { + Draft = 'Draft', + Open = 'Open', + Paid = 'Paid', + Uncollectible = 'Uncollectible', + Void = 'Void', +} + export enum NewFeaturesKind { EarlyAccess = 'EarlyAccess', } @@ -44,6 +52,29 @@ export enum Permission { Write = 'Write', } +export enum SubscriptionPlan { + Enterprise = 'Enterprise', + Free = 'Free', + Pro = 'Pro', + Team = 'Team', +} + +export enum SubscriptionRecurring { + Monthly = 'Monthly', + Yearly = 'Yearly', +} + +export enum SubscriptionStatus { + Active = 'Active', + Canceled = 'Canceled', + Incomplete = 'Incomplete', + IncompleteExpired = 'IncompleteExpired', + PastDue = 'PastDue', + Paused = 'Paused', + Trialing = 'Trialing', + Unpaid = 'Unpaid', +} + export interface UpdateWorkspaceInput { id: Scalars['ID']['input']; /** is Public workspace */ @@ -173,7 +204,7 @@ export type GetCurrentUserQuery = { avatarUrl: string | null; createdAt: string | null; token: { __typename?: 'TokenType'; sessionToken: string | null }; - }; + } | null; }; export type GetInviteInfoQueryVariables = Exact<{ diff --git a/yarn.lock b/yarn.lock index 9445b44dcf..40dfb5747c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -671,6 +671,7 @@ __metadata: "@nestjs/apollo": "npm:^12.0.9" "@nestjs/common": "npm:^10.2.7" "@nestjs/core": "npm:^10.2.7" + "@nestjs/event-emitter": "npm:^2.0.2" "@nestjs/graphql": "npm:^12.0.9" "@nestjs/platform-express": "npm:^10.2.7" "@nestjs/platform-socket.io": "npm:^10.2.7" @@ -735,6 +736,7 @@ __metadata: semver: "npm:^7.5.4" sinon: "npm:^16.1.0" socket.io: "npm:^4.7.2" + stripe: "npm:^13.6.0" supertest: "npm:^6.3.3" ts-node: "npm:^10.9.1" typescript: "npm:^5.2.2" @@ -7280,6 +7282,19 @@ __metadata: languageName: node linkType: hard +"@nestjs/event-emitter@npm:^2.0.2": + version: 2.0.2 + resolution: "@nestjs/event-emitter@npm:2.0.2" + dependencies: + eventemitter2: "npm:6.4.9" + peerDependencies: + "@nestjs/common": ^8.0.0 || ^9.0.0 || ^10.0.0 + "@nestjs/core": ^8.0.0 || ^9.0.0 || ^10.0.0 + reflect-metadata: ^0.1.12 + checksum: 9c7d2645b14bef5a9d26a8fbafb5963e18c9c15e267980c55abd913c8af9215ae363b8c0fc78711c22126e0a973f80aec8b8e962a64e699f523128d11c033894 + languageName: node + linkType: hard + "@nestjs/graphql@npm:^12.0.9": version: 12.0.9 resolution: "@nestjs/graphql@npm:12.0.9" @@ -13432,6 +13447,13 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:>=8.1.0": + version: 20.6.2 + resolution: "@types/node@npm:20.6.2" + checksum: 4b150698cf90c211d4f2f021618f06c33a337d74e9a0ce10ec2e7123f02aacc231eff62118101f56de75f7be309c2da6eb0edb8388d501d4195c50bb919c7a05 + languageName: node + linkType: hard + "@types/node@npm:^16.0.0": version: 16.18.58 resolution: "@types/node@npm:16.18.58" @@ -20137,6 +20159,13 @@ __metadata: languageName: node linkType: hard +"eventemitter2@npm:6.4.9": + version: 6.4.9 + resolution: "eventemitter2@npm:6.4.9" + checksum: b829b1c6b11e15926b635092b5ad62b4463d1c928859831dcae606e988cf41893059e3541f5a8209d21d2f15314422ddd4d84d20830b4bf44978608d15b06b08 + languageName: node + linkType: hard + "eventemitter3@npm:^3.1.0": version: 3.1.2 resolution: "eventemitter3@npm:3.1.2" @@ -31941,6 +31970,16 @@ __metadata: languageName: node linkType: hard +"stripe@npm:^13.6.0": + version: 13.6.0 + resolution: "stripe@npm:13.6.0" + dependencies: + "@types/node": "npm:>=8.1.0" + qs: "npm:^6.11.0" + checksum: 3fae1ed3dc845166c36fb28e4297ec770bb1f1b35e88b0166c465a31d41216203341b1055bf63b653fa3c66cd5d2eb72fdfaec9b58a7d467d207645a12b2cde0 + languageName: node + linkType: hard + "strnum@npm:^1.0.5": version: 1.0.5 resolution: "strnum@npm:1.0.5" From 1d62133f4ffab448c7ac0d16eaa4188959dd2ad6 Mon Sep 17 00:00:00 2001 From: forehalo Date: Thu, 19 Oct 2023 10:08:16 +0800 Subject: [PATCH 02/27] feat(core): impl subscription plans setting --- .../setting-modal/general-setting/index.tsx | 23 +- .../general-setting/plans/index.tsx | 417 ++++++++++++++++++ .../general-setting/plans/style.css.ts | 104 +++++ .../src/graphql/cancel-subscription.gql | 8 + .../src/graphql/create-checkout-link.gql | 3 + .../frontend/graphql/src/graphql/index.ts | 105 +++++ .../frontend/graphql/src/graphql/invoices.gql | 15 + .../frontend/graphql/src/graphql/prices.gql | 9 + .../graphql/src/graphql/subscription.gql | 14 + .../graphql/update-subscription-billing.gql | 8 + packages/frontend/graphql/src/schema.ts | 124 ++++++ 11 files changed, 829 insertions(+), 1 deletion(-) create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts create mode 100644 packages/frontend/graphql/src/graphql/cancel-subscription.gql create mode 100644 packages/frontend/graphql/src/graphql/create-checkout-link.gql create mode 100644 packages/frontend/graphql/src/graphql/invoices.gql create mode 100644 packages/frontend/graphql/src/graphql/prices.gql create mode 100644 packages/frontend/graphql/src/graphql/subscription.gql create mode 100644 packages/frontend/graphql/src/graphql/update-subscription-billing.gql 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 a57f442f1d..9a1c329fd4 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 @@ -9,6 +9,7 @@ import type { ReactElement, SVGProps } from 'react'; import { AboutAffine } from './about'; import { AppearanceSettings } from './appearance'; +import { AFFiNECloudPlans } from './plans'; import { Plugins } from './plugins'; import { Shortcuts } from './shortcuts'; @@ -16,7 +17,9 @@ export type GeneralSettingKeys = | 'shortcuts' | 'appearance' | 'plugins' - | 'about'; + | 'about' + | 'plans' + | 'billing'; interface GeneralSettingListItem { key: GeneralSettingKeys; @@ -43,6 +46,22 @@ export const useGeneralSettingList = (): GeneralSettingList => { icon: KeyboardIcon, testId: 'shortcuts-panel-trigger', }, + { + key: 'plans', + // TODO: i18n + title: 'AFFiNE Cloud Plans', + // TODO: icon + icon: KeyboardIcon, + testId: 'plans-panel-trigger', + }, + { + key: 'billing', + // TODO: i18n + title: 'Billing', + // TODO: icon + icon: KeyboardIcon, + testId: 'billing-panel-trigger', + }, { key: 'plugins', title: 'Plugins', @@ -72,6 +91,8 @@ export const GeneralSetting = ({ generalKey }: GeneralSettingProps) => { return ; case 'about': return ; + case 'plans': + 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 new file mode 100644 index 0000000000..1c79de89f2 --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx @@ -0,0 +1,417 @@ +import { RadioButton, RadioButtonGroup } from '@affine/component'; +import { SettingHeader } from '@affine/component/setting-components'; +import { + cancelSubscriptionMutation, + checkoutMutation, + pricesQuery, + SubscriptionPlan, + subscriptionQuery, + SubscriptionRecurring, + updateSubscriptionMutation, +} from '@affine/graphql'; +import { useMutation, useQuery } from '@affine/workspace/affine/gql'; +import { Button } from '@toeverything/components/button'; +import { + type PropsWithChildren, + Suspense, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +import * as styles from './style.css'; + +interface FixedPrice { + type: 'fixed'; + plan: SubscriptionPlan; + price: string; + yearlyPrice: string; + discount?: string; + benefits: string[]; +} + +interface DynamicPrice { + type: 'dynamic'; + plan: SubscriptionPlan; + contact: boolean; + benefits: string[]; +} + +// TODO: i18n all things +const planDetail = new Map([ + [ + SubscriptionPlan.Free, + { + type: 'fixed', + plan: SubscriptionPlan.Free, + price: '0', + yearlyPrice: '0', + benefits: [ + 'Unlimited local workspace', + 'Unlimited login devices', + 'Unlimited blocks', + 'AFFiNE Cloud Storage 10GB', + 'The maximum file size is 10M', + 'Number of members per Workspace ≤ 3', + ], + }, + ], + [ + SubscriptionPlan.Pro, + { + type: 'fixed', + plan: SubscriptionPlan.Pro, + price: '1', + yearlyPrice: '1', + benefits: [ + 'Unlimited local workspace', + 'Unlimited login devices', + 'Unlimited blocks', + 'AFFiNE Cloud Storage 100GB', + 'The maximum file size is 500M', + 'Number of members per Workspace ≤ 10', + ], + }, + ], + [ + SubscriptionPlan.Team, + { + type: 'dynamic', + plan: SubscriptionPlan.Team, + contact: true, + benefits: [ + 'Best team workspace for collaboration and knowledge distilling.', + 'Focusing on what really matters with team project management and automation.', + 'Pay for seats, fits all team size.', + ], + }, + ], + [ + SubscriptionPlan.Enterprise, + { + type: 'dynamic', + plan: SubscriptionPlan.Enterprise, + contact: true, + benefits: [ + 'Solutions & best practices for dedicated needs.', + 'Embedable & interrogations with IT support.', + ], + }, + ], +]); + +const Settings = () => { + const { data, mutate } = useQuery({ + query: subscriptionQuery, + }); + + const { + data: { prices }, + } = useQuery({ + query: pricesQuery, + }); + + prices.forEach(price => { + const detail = planDetail.get(price.plan); + + if (detail?.type === 'fixed') { + detail.price = (price.amount / 100).toFixed(2); + detail.yearlyPrice = (price.yearlyAmount / 100 / 12).toFixed(2); + detail.discount = ( + (1 - price.yearlyAmount / 12 / price.amount) * + 100 + ).toFixed(2); + } + }); + + const loggedIn = !!data.currentUser; + const subscription = data.currentUser?.subscription; + + const [recurring, setRecurring] = useState( + subscription?.recurring ?? SubscriptionRecurring.Monthly + ); + + const currentPlan = subscription?.plan ?? SubscriptionPlan.Free; + const currentRecurring = + subscription?.recurring ?? SubscriptionRecurring.Monthly; + + const refresh = useCallback(() => { + mutate(); + }, [mutate]); + + const yearlyDiscount = ( + planDetail.get(SubscriptionPlan.Pro) as FixedPrice | undefined + )?.discount; + + return ( + <> + + You are current on the {currentPlan} plan. If you have any + questions, please contact our{' '} + {/*TODO: add action*/}customer support. +

+ } + /> +
+ + {Object.values(SubscriptionRecurring).map(plan => ( + + {plan} + {plan === SubscriptionRecurring.Yearly && yearlyDiscount && ( + + {yearlyDiscount}% off + + )} + + ))} + + {/* TODO: plan cards horizontal scroll behavior is not the same as design */} + {/* 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 + + )} +

+

+ + $ + {detail.type === 'dynamic' + ? '?' + : recurring === SubscriptionRecurring.Monthly + ? detail.price + : detail.yearlyPrice} + + per month +

+ { + // branches: + // if contact => 'Contact Sales' + // if not signed in: + // if free => 'Sign up free' + // else => 'Buy Pro' + // else + // if isCurrent => 'Current Plan' + // else if free => 'Downgrade' + // else if currentRecurring !== recurring => 'Change to {recurring} Billing' + // else => 'Upgrade' + // TODO: should replace with components with proper actions + detail.type === 'dynamic' ? ( + + ) : loggedIn ? ( + isCurrent ? ( + + ) : detail.plan === SubscriptionPlan.Free ? ( + + ) : currentRecurring !== recurring ? ( + + ) : ( + + ) + ) : ( + + {detail.plan === SubscriptionPlan.Free + ? 'Sign up free' + : 'Buy Pro'} + + ) + } +
+
+ {detail.benefits.map((content, i) => ( +
+
+ {/* TODO: icons */} + {detail.type == 'dynamic' ? '·' : '✅'} +
+ {content} +
+ ))} +
+
+ ); + })} +
+ + See all plans →{/* TODO: icon */} + +
+ + ); +}; + +const Downgrade = ({ onActionDone }: { onActionDone: () => void }) => { + const { isMutating, trigger } = useMutation({ + mutation: cancelSubscriptionMutation, + }); + + const downgrade = useCallback(() => { + trigger(null, { onSuccess: onActionDone }); + }, [trigger, onActionDone]); + + return ( + + ); +}; + +const Upgrade = ({ + recurring, + onActionDone, +}: { + recurring: SubscriptionRecurring; + onActionDone: () => void; +}) => { + const { isMutating, trigger, data } = useMutation({ + mutation: checkoutMutation, + }); + + const upgrade = useCallback(() => { + trigger({ recurring }); + }, [trigger, recurring]); + + const newTabRef = useRef(null); + + useEffect(() => { + if (data?.checkout) { + if (newTabRef.current) { + newTabRef.current.focus(); + } else { + // FIXME: safari prevents from opening new tab by window api + // TODO(@xp): what if electron? + const newTab = window.open( + data.checkout, + '_blank', + 'noopener noreferrer' + ); + + if (newTab) { + newTabRef.current = newTab; + const update = () => { + onActionDone(); + }; + newTab.addEventListener('close', update); + + return () => newTab.removeEventListener('close', update); + } + } + } + + return; + }, [data?.checkout, onActionDone]); + + return ( + + ); +}; + +const ChangeRecurring = ({ + from: _from /* TODO: from can be useful when showing confirmation modal */, + to, + onActionDone, +}: { + from: SubscriptionRecurring; + to: SubscriptionRecurring; + onActionDone: () => void; +}) => { + const { isMutating, trigger } = useMutation({ + mutation: updateSubscriptionMutation, + }); + + const change = useCallback(() => { + trigger({ recurring: to }, { onSuccess: onActionDone }); + }, [trigger, onActionDone, to]); + + return ( + + ); +}; + +const ContactSales = () => { + return ( + // TODO: add action + + ); +}; + +const CurrentPlan = () => { + return ; +}; + +const SignupAction = ({ children }: PropsWithChildren) => { + // TODO: add login action + return ( + + ); +}; + +export const AFFiNECloudPlans = () => { + return ( + // TODO: loading skeleton + // TODO: Error Boundary + + + + ); +}; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts new file mode 100644 index 0000000000..d12bb790eb --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts @@ -0,0 +1,104 @@ +import { style } from '@vanilla-extract/css'; + +export const wrapper = style({ + width: '100%', +}); +export const recurringRadioGroup = style({ + width: '256px', +}); + +export const radioButtonDiscount = style({ + marginLeft: '4px', + color: 'var(--affine-primary-color)', +}); + +export const planCardsWrapper = style({ + marginTop: '24px', + display: 'flex', + overflowX: 'auto', +}); + +export const planCard = style({ + minHeight: '426px', + minWidth: '258px', + borderRadius: '16px', + padding: '20px', + border: '1px solid var(--affine-border-color)', + + selectors: { + '&:not(:last-child)': { + marginRight: '16px', + }, + }, +}); + +export const currentPlanCard = style([ + planCard, + { + borderWidth: '2px', + borderColor: 'var(--affine-primary-color)', + }, +]); + +export const discountLabel = style({ + color: 'var(--affine-primary-color)', + marginLeft: '8px', + lineHeight: '20px', + fontSize: 'var(--affine-font-xs)', + fontWeight: 500, + padding: '0 4px', + backgroundColor: 'var(--affine-blue-50)', + borderRadius: '4px', + display: 'inline-block', + height: '100%', +}); + +export const planTitle = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: '10px', + fontWeight: 600, +}); + +export const planPrice = style({ + fontSize: 'var(--affine-font-h-5)', + marginRight: '8px', +}); + +export const planPriceDesc = style({ + color: 'var(--affine-text-secondary-color)', + fontSize: 'var(--affine-font-sm)', +}); + +export const planAction = style({ + width: '100%', +}); + +export const planBenefits = style({ + marginTop: '20px', + fontSize: 'var(--affine-font-xs)', +}); + +export const planBenefit = style({ + display: 'flex', + selectors: { + '&:not(:last-child)': { + marginBottom: '8px', + }, + }, +}); + +export const planBenefitIcon = style({ + display: 'inline-block', + marginRight: '8px', +}); + +export const allPlansLink = style({ + display: 'block', + marginTop: '36px', + color: 'var(--affine-primary-color)', + background: 'transparent', + borderColor: 'transparent', + fontSize: 'var(--affine-font-xs)', +}); diff --git a/packages/frontend/graphql/src/graphql/cancel-subscription.gql b/packages/frontend/graphql/src/graphql/cancel-subscription.gql new file mode 100644 index 0000000000..128e206781 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/cancel-subscription.gql @@ -0,0 +1,8 @@ +mutation cancelSubscription { + cancelSubscription { + id + status + nextBillAt + canceledAt + } +} diff --git a/packages/frontend/graphql/src/graphql/create-checkout-link.gql b/packages/frontend/graphql/src/graphql/create-checkout-link.gql new file mode 100644 index 0000000000..b4e4704e97 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/create-checkout-link.gql @@ -0,0 +1,3 @@ +mutation checkout($recurring: SubscriptionRecurring!) { + checkout(recurring: $recurring) +} diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index f31b3c5c8c..29330c8fed 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -79,6 +79,22 @@ query allBlobSizes { }`, }; +export const cancelSubscriptionMutation = { + id: 'cancelSubscriptionMutation' as const, + operationName: 'cancelSubscription', + definitionName: 'cancelSubscription', + containsFile: false, + query: ` +mutation cancelSubscription { + cancelSubscription { + id + status + nextBillAt + canceledAt + } +}`, +}; + export const changeEmailMutation = { id: 'changeEmailMutation' as const, operationName: 'changeEmail', @@ -111,6 +127,17 @@ mutation changePassword($token: String!, $newPassword: String!) { }`, }; +export const checkoutMutation = { + id: 'checkoutMutation' as const, + operationName: 'checkout', + definitionName: 'checkout', + containsFile: false, + query: ` +mutation checkout($recurring: SubscriptionRecurring!) { + checkout(recurring: $recurring) +}`, +}; + export const createWorkspaceMutation = { id: 'createWorkspaceMutation' as const, operationName: 'createWorkspace', @@ -321,6 +348,29 @@ query getWorkspaces { }`, }; +export const invoicesQuery = { + id: 'invoicesQuery' as const, + operationName: 'invoices', + definitionName: 'currentUser', + containsFile: false, + query: ` +query invoices($take: Int!, $skip: Int!) { + currentUser { + invoices(take: $take, skip: $skip) { + id + status + plan + recurring + currency + amount + reason + lastPaymentError + createdAt + } + } +}`, +}; + export const leaveWorkspaceMutation = { id: 'leaveWorkspaceMutation' as const, operationName: 'leaveWorkspace', @@ -336,6 +386,23 @@ mutation leaveWorkspace($workspaceId: String!, $workspaceName: String!, $sendLea }`, }; +export const pricesQuery = { + id: 'pricesQuery' as const, + operationName: 'prices', + definitionName: 'prices', + containsFile: false, + query: ` +query prices { + prices { + type + plan + currency + amount + yearlyAmount + } +}`, +}; + export const removeAvatarMutation = { id: 'removeAvatarMutation' as const, operationName: 'removeAvatar', @@ -469,6 +536,44 @@ mutation signUp($name: String!, $email: String!, $password: String!) { }`, }; +export const subscriptionQuery = { + id: 'subscriptionQuery' as const, + operationName: 'subscription', + definitionName: 'currentUser', + containsFile: false, + query: ` +query subscription { + currentUser { + subscription { + id + status + plan + recurring + start + end + nextBillAt + canceledAt + } + } +}`, +}; + +export const updateSubscriptionMutation = { + id: 'updateSubscriptionMutation' as const, + operationName: 'updateSubscription', + definitionName: 'updateSubscriptionRecurring', + containsFile: false, + query: ` +mutation updateSubscription($recurring: SubscriptionRecurring!) { + updateSubscriptionRecurring(recurring: $recurring) { + id + plan + recurring + nextBillAt + } +}`, +}; + export const uploadAvatarMutation = { id: 'uploadAvatarMutation' as const, operationName: 'uploadAvatar', diff --git a/packages/frontend/graphql/src/graphql/invoices.gql b/packages/frontend/graphql/src/graphql/invoices.gql new file mode 100644 index 0000000000..569b77730f --- /dev/null +++ b/packages/frontend/graphql/src/graphql/invoices.gql @@ -0,0 +1,15 @@ +query invoices($take: Int!, $skip: Int!) { + currentUser { + invoices(take: $take, skip: $skip) { + id + status + plan + recurring + currency + amount + reason + lastPaymentError + createdAt + } + } +} diff --git a/packages/frontend/graphql/src/graphql/prices.gql b/packages/frontend/graphql/src/graphql/prices.gql new file mode 100644 index 0000000000..eb4a75e7bd --- /dev/null +++ b/packages/frontend/graphql/src/graphql/prices.gql @@ -0,0 +1,9 @@ +query prices { + prices { + type + plan + currency + amount + yearlyAmount + } +} diff --git a/packages/frontend/graphql/src/graphql/subscription.gql b/packages/frontend/graphql/src/graphql/subscription.gql new file mode 100644 index 0000000000..0072350f1f --- /dev/null +++ b/packages/frontend/graphql/src/graphql/subscription.gql @@ -0,0 +1,14 @@ +query subscription { + currentUser { + subscription { + id + status + plan + recurring + start + end + nextBillAt + canceledAt + } + } +} diff --git a/packages/frontend/graphql/src/graphql/update-subscription-billing.gql b/packages/frontend/graphql/src/graphql/update-subscription-billing.gql new file mode 100644 index 0000000000..1464d399d6 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/update-subscription-billing.gql @@ -0,0 +1,8 @@ +mutation updateSubscription($recurring: SubscriptionRecurring!) { + updateSubscriptionRecurring(recurring: $recurring) { + id + plan + recurring + nextBillAt + } +} diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 3a84e42fe4..e38e9b760c 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -130,6 +130,21 @@ export type AllBlobSizesQuery = { collectAllBlobSizes: { __typename?: 'WorkspaceBlobSizes'; size: number }; }; +export type CancelSubscriptionMutationVariables = Exact<{ + [key: string]: never; +}>; + +export type CancelSubscriptionMutation = { + __typename?: 'Mutation'; + cancelSubscription: { + __typename?: 'UserSubscription'; + id: string; + status: SubscriptionStatus; + nextBillAt: string | null; + canceledAt: string | null; + }; +}; + export type ChangeEmailMutationVariables = Exact<{ token: Scalars['String']['input']; }>; @@ -161,6 +176,12 @@ export type ChangePasswordMutation = { }; }; +export type CheckoutMutationVariables = Exact<{ + recurring: SubscriptionRecurring; +}>; + +export type CheckoutMutation = { __typename?: 'Mutation'; checkout: string }; + export type CreateWorkspaceMutationVariables = Exact<{ init: Scalars['Upload']['input']; }>; @@ -328,6 +349,30 @@ export type GetWorkspacesQuery = { workspaces: Array<{ __typename?: 'WorkspaceType'; id: string }>; }; +export type InvoicesQueryVariables = Exact<{ + take: Scalars['Int']['input']; + skip: Scalars['Int']['input']; +}>; + +export type InvoicesQuery = { + __typename?: 'Query'; + currentUser: { + __typename?: 'UserType'; + invoices: Array<{ + __typename?: 'UserInvoice'; + id: string; + status: InvoiceStatus; + plan: SubscriptionPlan; + recurring: SubscriptionRecurring; + currency: string; + amount: number; + reason: string; + lastPaymentError: string | null; + createdAt: string; + }>; + } | null; +}; + export type LeaveWorkspaceMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; workspaceName: Scalars['String']['input']; @@ -339,6 +384,20 @@ export type LeaveWorkspaceMutation = { leaveWorkspace: boolean; }; +export type PricesQueryVariables = Exact<{ [key: string]: never }>; + +export type PricesQuery = { + __typename?: 'Query'; + prices: Array<{ + __typename?: 'SubscriptionPrice'; + type: string; + plan: SubscriptionPlan; + currency: string; + amount: number; + yearlyAmount: number; + }>; +}; + export type RemoveAvatarMutationVariables = Exact<{ [key: string]: never }>; export type RemoveAvatarMutation = { @@ -451,6 +510,41 @@ export type SignUpMutation = { }; }; +export type SubscriptionQueryVariables = Exact<{ [key: string]: never }>; + +export type SubscriptionQuery = { + __typename?: 'Query'; + currentUser: { + __typename?: 'UserType'; + subscription: { + __typename?: 'UserSubscription'; + id: string; + status: SubscriptionStatus; + plan: SubscriptionPlan; + recurring: SubscriptionRecurring; + start: string; + end: string; + nextBillAt: string | null; + canceledAt: string | null; + } | null; + } | null; +}; + +export type UpdateSubscriptionMutationVariables = Exact<{ + recurring: SubscriptionRecurring; +}>; + +export type UpdateSubscriptionMutation = { + __typename?: 'Mutation'; + updateSubscriptionRecurring: { + __typename?: 'UserSubscription'; + id: string; + plan: SubscriptionPlan; + recurring: SubscriptionRecurring; + nextBillAt: string | null; + }; +}; + export type UploadAvatarMutationVariables = Exact<{ avatar: Scalars['Upload']['input']; }>; @@ -570,6 +664,21 @@ export type Queries = name: 'getWorkspacesQuery'; variables: GetWorkspacesQueryVariables; response: GetWorkspacesQuery; + } + | { + name: 'invoicesQuery'; + variables: InvoicesQueryVariables; + response: InvoicesQuery; + } + | { + name: 'pricesQuery'; + variables: PricesQueryVariables; + response: PricesQuery; + } + | { + name: 'subscriptionQuery'; + variables: SubscriptionQueryVariables; + response: SubscriptionQuery; }; export type Mutations = @@ -583,6 +692,11 @@ export type Mutations = variables: SetBlobMutationVariables; response: SetBlobMutation; } + | { + name: 'cancelSubscriptionMutation'; + variables: CancelSubscriptionMutationVariables; + response: CancelSubscriptionMutation; + } | { name: 'changeEmailMutation'; variables: ChangeEmailMutationVariables; @@ -593,6 +707,11 @@ export type Mutations = variables: ChangePasswordMutationVariables; response: ChangePasswordMutation; } + | { + name: 'checkoutMutation'; + variables: CheckoutMutationVariables; + response: CheckoutMutation; + } | { name: 'createWorkspaceMutation'; variables: CreateWorkspaceMutationVariables; @@ -668,6 +787,11 @@ export type Mutations = variables: SignUpMutationVariables; response: SignUpMutation; } + | { + name: 'updateSubscriptionMutation'; + variables: UpdateSubscriptionMutationVariables; + response: UpdateSubscriptionMutation; + } | { name: 'uploadAvatarMutation'; variables: UploadAvatarMutationVariables; From 858a1da35fab4c71b53f5a38d0b1b073fb9b744a Mon Sep 17 00:00:00 2001 From: liuyi Date: Fri, 20 Oct 2023 09:42:33 +0800 Subject: [PATCH 03/27] 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; From 95d37fc63fd6ea8818b80a258257765fdd488b54 Mon Sep 17 00:00:00 2001 From: liuyi Date: Fri, 20 Oct 2023 16:03:28 +0800 Subject: [PATCH 04/27] refactor(core): make subscription hook (#4669) --- .../general-setting/billing/index.tsx | 58 +++++---- .../general-setting/plans/index.tsx | 120 ++++++++++-------- .../core/src/hooks/use-subscription.ts | 40 ++++++ 3 files changed, 146 insertions(+), 72 deletions(-) create mode 100644 packages/frontend/core/src/hooks/use-subscription.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 fa68c249e5..ce82b5b83b 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 @@ -12,7 +12,6 @@ import { pricesQuery, resumeSubscriptionMutation, SubscriptionPlan, - subscriptionQuery, SubscriptionRecurring, SubscriptionStatus, } from '@affine/graphql'; @@ -20,10 +19,14 @@ 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 { Suspense, useCallback } from 'react'; import { openSettingModalAtom } from '../../../../../atoms'; import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status'; +import { + type SubscriptionMutator, + useUserSubscription, +} from '../../../../../hooks/use-subscription'; import * as styles from './style.css'; export const BillingSettings = () => { @@ -56,14 +59,11 @@ export const BillingSettings = () => { }; const SubscriptionSettings = () => { - const { data: subscriptionQueryResult } = useQuery({ - query: subscriptionQuery, - }); + const [subscription, mutateSubscription] = useUserSubscription(); const { data: pricesQueryResult } = useQuery({ query: pricesQuery, }); - const subscription = subscriptionQueryResult.currentUser?.subscription; const plan = subscription?.plan ?? SubscriptionPlan.Free; const recurring = subscription?.recurring ?? SubscriptionRecurring.Monthly; @@ -123,7 +123,7 @@ const SubscriptionSettings = () => { subscription.end ).toLocaleDateString()}`} > - + ) : ( { subscription.end ).toLocaleDateString()}`} > - + )} @@ -166,20 +166,18 @@ const PlanAction = ({ plan }: { plan: string }) => { const PaymentMethodUpdater = () => { // TODO: open stripe customer portal - const { isMutating, trigger, data } = useMutation({ + const { isMutating, trigger } = useMutation({ mutation: createCustomerPortalMutation, }); const update = useCallback(() => { - trigger(); + trigger(null, { + onSuccess: data => { + window.open(data.createCustomerPortal, '_blank', 'noopener noreferrer'); + }, + }); }, [trigger]); - useEffect(() => { - if (data?.createCustomerPortal) { - window.open(data.createCustomerPortal, '_blank', 'noopener noreferrer'); - } - }, [data]); - return ( + {Object.values(SubscriptionRecurring).map(recurring => ( + + {getRecurringLabel(recurring)} + {recurring === SubscriptionRecurring.Yearly && yearlyDiscount && ( + + {yearlyDiscount}% off + + )} + + ))} + ); -}; -const Upgrade = ({ - recurring, - onSubscriptionUpdate, -}: { - recurring: SubscriptionRecurring; - onSubscriptionUpdate: SubscriptionMutator; -}) => { - const { isMutating, trigger } = useMutation({ - mutation: checkoutMutation, - }); - - const newTabRef = useRef(null); - - const onClose = useCallback(() => { - newTabRef.current = null; - onSubscriptionUpdate(); - }, [onSubscriptionUpdate]); - - const upgrade = useCallback(() => { - if (newTabRef.current) { - newTabRef.current.focus(); - } else { - trigger( - { recurring }, - { - onSuccess: data => { - // FIXME: safari prevents from opening new tab by window api - // TODO(@xp): what if electron? - const newTab = window.open( - data.checkout, - '_blank', - 'noopener noreferrer' - ); - - if (newTab) { - newTabRef.current = newTab; - - newTab.addEventListener('close', onClose); - } - }, - } - ); - } - }, [trigger, recurring, onClose]); - - useEffect(() => { - return () => { - if (newTabRef.current) { - newTabRef.current.removeEventListener('close', onClose); - newTabRef.current = null; - } - }; - }, [onClose]); + const scroll = ( +

+ {Array.from(planDetail.values()).map(detail => { + return ( + + ); + })} +
+ ); return ( - - ); -}; - -const ChangeRecurring = ({ - from: _from /* TODO: from can be useful when showing confirmation modal */, - to, - onSubscriptionUpdate, -}: { - from: SubscriptionRecurring; - to: SubscriptionRecurring; - onSubscriptionUpdate: SubscriptionMutator; -}) => { - const { isMutating, trigger } = useMutation({ - mutation: updateSubscriptionMutation, - }); - - const change = useCallback(() => { - trigger( - { recurring: to }, - { - onSuccess: data => { - onSubscriptionUpdate(data.updateSubscriptionRecurring); - }, - } - ); - }, [trigger, onSubscriptionUpdate, to]); - - return ( - - ); -}; - -const ContactSales = () => { - return ( - // TODO: add action - - ); -}; - -const CurrentPlan = () => { - return ; -}; - -const SignupAction = ({ children }: PropsWithChildren) => { - // TODO: add login action - return ( - + ); }; export const AFFiNECloudPlans = () => { return ( - // TODO: loading skeleton // TODO: Error Boundary - + }> ); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.css.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.css.ts new file mode 100644 index 0000000000..72ab6ab5b0 --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.css.ts @@ -0,0 +1,36 @@ +import { style } from '@vanilla-extract/css'; + +export const plansLayoutRoot = style({ + display: 'flex', + flexDirection: 'column', + gap: '24px', +}); + +export const scrollArea = style({ + marginLeft: 'calc(-1 * var(--setting-modal-gap-x))', + paddingLeft: 'var(--setting-modal-gap-x)', + width: 'var(--setting-modal-width)', + overflowX: 'auto', + scrollSnapType: 'x mandatory', + paddingBottom: '21px', + + '::-webkit-scrollbar': { + display: 'block', + height: '5px', + background: 'transparent', + }, + '::-webkit-scrollbar-thumb': { + background: 'var(--affine-icon-secondary)', + borderRadius: '5px', + }, +}); + +export const allPlansLink = style({ + display: 'flex', + alignItems: 'center', + gap: '4px', + color: 'var(--affine-link-color)', + background: 'transparent', + borderColor: 'transparent', + fontSize: 'var(--affine-font-xs)', +}); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx new file mode 100644 index 0000000000..424e944cd0 --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx @@ -0,0 +1,52 @@ +import { SettingHeader } from '@affine/component/setting-components'; +import { ArrowRightBigIcon } from '@blocksuite/icons'; +import type { HtmlHTMLAttributes, ReactNode } from 'react'; + +import * as styles from './layout.css'; + +export interface PlanLayoutProps + extends Omit, 'title'> { + title?: ReactNode; + subtitle: ReactNode; + tabs: ReactNode; + scroll: ReactNode; + footer?: ReactNode; + scrollRef?: React.RefObject; +} + +const SeeAllLink = () => ( + + See all plans + {} + +); + +export const PlanLayout = ({ + subtitle, + tabs, + scroll, + title = 'Pricing Plans', + footer = , + scrollRef, +}: PlanLayoutProps) => { + return ( +
+ {/* TODO: SettingHeader component shouldn't have margin itself */} + + {tabs} +
+ {scroll} +
+ {footer} +
+ ); +}; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx new file mode 100644 index 0000000000..6dd027fe79 --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx @@ -0,0 +1,381 @@ +import type { + Subscription, + SubscriptionMutator, +} from '@affine/core/hooks/use-subscription'; +import { + cancelSubscriptionMutation, + checkoutMutation, + SubscriptionPlan, + SubscriptionRecurring, + updateSubscriptionMutation, +} from '@affine/graphql'; +import { useMutation } from '@affine/workspace/affine/gql'; +import { DoneIcon } from '@blocksuite/icons'; +import { Button } from '@toeverything/components/button'; +import { type PropsWithChildren, useCallback, useEffect, useRef } from 'react'; + +import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status'; +import { BulledListIcon } from './icons/bulled-list'; +import * as styles from './style.css'; + +export interface FixedPrice { + type: 'fixed'; + plan: SubscriptionPlan; + price: string; + yearlyPrice: string; + discount?: string; + benefits: string[]; +} + +export interface DynamicPrice { + type: 'dynamic'; + plan: SubscriptionPlan; + contact: boolean; + benefits: string[]; +} + +interface PlanCardProps { + detail: FixedPrice | DynamicPrice; + subscription?: Subscription | null; + recurring: string; + onSubscriptionUpdate: SubscriptionMutator; +} + +export function getPlanDetail() { + // const t = useAFFiNEI18N(); + + // TODO: i18n all things + return new Map([ + [ + SubscriptionPlan.Free, + { + type: 'fixed', + plan: SubscriptionPlan.Free, + price: '0', + yearlyPrice: '0', + benefits: [ + 'Unlimited local workspace', + 'Unlimited login devices', + 'Unlimited blocks', + 'AFFiNE Cloud Storage 10GB', + 'The maximum file size is 10M', + 'Number of members per Workspace ≤ 3', + ], + }, + ], + [ + SubscriptionPlan.Pro, + { + type: 'fixed', + plan: SubscriptionPlan.Pro, + price: '1', + yearlyPrice: '1', + benefits: [ + 'Unlimited local workspace', + 'Unlimited login devices', + 'Unlimited blocks', + 'AFFiNE Cloud Storage 100GB', + 'The maximum file size is 500M', + 'Number of members per Workspace ≤ 10', + ], + }, + ], + [ + SubscriptionPlan.Team, + { + type: 'dynamic', + plan: SubscriptionPlan.Team, + contact: true, + benefits: [ + 'Best team workspace for collaboration and knowledge distilling.', + 'Focusing on what really matters with team project management and automation.', + 'Pay for seats, fits all team size.', + ], + }, + ], + [ + SubscriptionPlan.Enterprise, + { + type: 'dynamic', + plan: SubscriptionPlan.Enterprise, + contact: true, + benefits: [ + 'Solutions & best practices for dedicated needs.', + 'Embedable & interrogations with IT support.', + ], + }, + ], + ]); +} + +export const PlanCard = ({ + detail, + subscription, + recurring, + onSubscriptionUpdate, +}: PlanCardProps) => { + const loggedIn = useCurrentLoginStatus() === 'authenticated'; + const currentPlan = subscription?.plan ?? SubscriptionPlan.Free; + const currentRecurring = subscription?.recurring; + + const isCurrent = + loggedIn && + detail.plan === currentPlan && + (currentPlan === SubscriptionPlan.Free + ? true + : currentRecurring === recurring); + + return ( +
+
+

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

+
+

+ {detail.type === 'dynamic' ? ( + Coming soon... + ) : ( + <> + + $ + {recurring === SubscriptionRecurring.Monthly + ? detail.price + : detail.yearlyPrice} + + per month + + )} +

+
+ { + // branches: + // if contact => 'Contact Sales' + // if not signed in: + // if free => 'Sign up free' + // else => 'Buy Pro' + // else + // if isCurrent => 'Current Plan' + // else if free => 'Downgrade' + // else if currentRecurring !== recurring => 'Change to {recurring} Billing' + // else => 'Upgrade' + // TODO: should replace with components with proper actions + detail.type === 'dynamic' ? ( + + ) : loggedIn ? ( + detail.plan === currentPlan && + (currentRecurring === recurring || + (!currentRecurring && detail.plan === SubscriptionPlan.Free)) ? ( + + ) : detail.plan === SubscriptionPlan.Free ? ( + + ) : currentRecurring !== recurring && + currentPlan === detail.plan ? ( + + ) : ( + + ) + ) : ( + + {detail.plan === SubscriptionPlan.Free + ? 'Sign up free' + : 'Buy Pro'} + + ) + } +
+
+ {detail.benefits.map((content, i) => ( +
+
+ {detail.type == 'dynamic' ? ( + + ) : ( + + )} +
+
{content}
+
+ ))} +
+
+ ); +}; + +const CurrentPlan = () => { + return ; +}; + +const Downgrade = ({ + onSubscriptionUpdate, +}: { + onSubscriptionUpdate: SubscriptionMutator; +}) => { + const { isMutating, trigger } = useMutation({ + mutation: cancelSubscriptionMutation, + }); + + const downgrade = useCallback(() => { + trigger(null, { + onSuccess: data => { + onSubscriptionUpdate(data.cancelSubscription); + }, + }); + }, [trigger, onSubscriptionUpdate]); + + return ( + + ); +}; + +const ContactSales = () => { + return ( + // TODO: add action + + ); +}; + +const Upgrade = ({ + recurring, + onSubscriptionUpdate, +}: { + recurring: SubscriptionRecurring; + onSubscriptionUpdate: SubscriptionMutator; +}) => { + const { isMutating, trigger } = useMutation({ + mutation: checkoutMutation, + }); + + const newTabRef = useRef(null); + + const onClose = useCallback(() => { + newTabRef.current = null; + onSubscriptionUpdate(); + }, [onSubscriptionUpdate]); + + const upgrade = useCallback(() => { + if (newTabRef.current) { + newTabRef.current.focus(); + } else { + trigger( + { recurring }, + { + onSuccess: data => { + // FIXME: safari prevents from opening new tab by window api + // TODO(@xp): what if electron? + const newTab = window.open( + data.checkout, + '_blank', + 'noopener noreferrer' + ); + + if (newTab) { + newTabRef.current = newTab; + + newTab.addEventListener('close', onClose); + } + }, + } + ); + } + }, [trigger, recurring, onClose]); + + useEffect(() => { + return () => { + if (newTabRef.current) { + newTabRef.current.removeEventListener('close', onClose); + newTabRef.current = null; + } + }; + }, [onClose]); + + return ( + + ); +}; + +const ChangeRecurring = ({ + from: _from /* TODO: from can be useful when showing confirmation modal */, + to, + onSubscriptionUpdate, +}: { + from: SubscriptionRecurring; + to: SubscriptionRecurring; + onSubscriptionUpdate: SubscriptionMutator; +}) => { + const { isMutating, trigger } = useMutation({ + mutation: updateSubscriptionMutation, + }); + + const change = useCallback(() => { + trigger( + { recurring: to }, + { + onSuccess: data => { + onSubscriptionUpdate(data.updateSubscriptionRecurring); + }, + } + ); + }, [trigger, onSubscriptionUpdate, to]); + + return ( + + ); +}; + +const SignupAction = ({ children }: PropsWithChildren) => { + // TODO: add login action + return ( + + ); +}; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/skeleton.css.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/skeleton.css.ts new file mode 100644 index 0000000000..abec1785a6 --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/skeleton.css.ts @@ -0,0 +1,29 @@ +import { style } from '@vanilla-extract/css'; + +export const plansWrapper = style({ + display: 'flex', + gap: '16px', +}); + +export const planItemCard = style({ + width: '258px', + height: '426px', + flexShrink: '0', + borderRadius: '16px', + backgroundColor: 'var(--affine-background-primary-color)', + border: '1px solid var(--affine-border-color)', + padding: '20px', + display: 'flex', + flexDirection: 'column', + gap: '20px', +}); + +export const planItemHeader = style({ + display: 'flex', + flexDirection: 'column', + gap: '10px', +}); +export const planItemContent = style({ + flexGrow: '1', + height: 0, +}); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/skeleton.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/skeleton.tsx new file mode 100644 index 0000000000..1ae9a390b7 --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/skeleton.tsx @@ -0,0 +1,60 @@ +import { Skeleton } from '@mui/material'; + +import { PlanLayout } from './layout'; +import * as styles from './skeleton.css'; + +/** + * Customize Skeleton component with rounded border radius + * @param param0 + * @returns + */ +const RoundedSkeleton = ({ + radius = 8, + ...props +}: { + radius?: number; +} & React.ComponentProps) => ( + +); + +const SubtitleSkeleton = () => ( + +); + +const TabsSkeleton = () => ( + // TODO: height should be `32px` by design + // but the RadioGroup component is not matching with the design currently + // set to `24px` for now to avoid blinking + +); + +const PlanItemSkeleton = () => ( +
+
+ + +
+ +
+ +
+
+); + +const ScrollSkeleton = () => ( +
+ + + +
+); + +export const PlansSkeleton = () => { + return ( + } + tabs={} + scroll={} + /> + ); +}; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts index c3587350d2..4189024ff9 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts @@ -14,16 +14,10 @@ export const radioButtonDiscount = style({ }); export const planCardsWrapper = style({ - marginLeft: 'calc(-1 * var(--setting-modal-gap-x))', - paddingLeft: 'var(--setting-modal-gap-x)', - paddingRight: 'calc(var(--setting-modal-gap-x) + 300px)', - width: 'var(--setting-modal-width)', - marginTop: '24px', + paddingRight: 'calc(var(--setting-modal-gap-x))', display: 'flex', - overflowX: 'auto', - scrollSnapType: 'x mandatory', - // TODO: should display the horizontal scrollbar, ensure the box-shadow is not clipped - paddingBottom: '21px', + gap: '16px', + width: 'fit-content', }); export const planCard = style({ @@ -35,9 +29,6 @@ export const planCard = style({ position: 'relative', selectors: { - '&:not(:last-child)': { - marginRight: '16px', - }, '&::before': { content: '', position: 'absolute', @@ -101,27 +92,27 @@ export const planAction = style({ export const planBenefits = style({ marginTop: '20px', fontSize: 'var(--affine-font-xs)', + display: 'flex', + flexDirection: 'column', + gap: '8px', }); export const planBenefit = style({ display: 'flex', - selectors: { - '&:not(:last-child)': { - marginBottom: '8px', - }, - }, + gap: '8px', + lineHeight: '20px', + alignItems: 'normal', + fontSize: '12px', }); export const planBenefitIcon = style({ - display: 'inline-block', - marginRight: '8px', + display: 'flex', + alignItems: 'center', + height: '20px', }); -export const allPlansLink = style({ - display: 'block', - marginTop: '36px', - color: 'var(--affine-primary-color)', - background: 'transparent', - borderColor: 'transparent', - fontSize: 'var(--affine-font-xs)', +export const planBenefitText = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', }); diff --git a/packages/frontend/core/src/components/affine/setting-modal/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/index.tsx index 349463a2f9..dbac104bd7 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/index.tsx @@ -54,7 +54,7 @@ export const SettingModal = ({ const paddingX = parseInt(computedStyle.paddingLeft, 10); modalContentRef.current?.style.setProperty( '--setting-modal-width', - `${contentWidth + marginX * 2 + paddingX * 2}px` + `${contentWidth + marginX * 2}px` ); modalContentRef.current?.style.setProperty( '--setting-modal-gap-x', From df69c908fea4d8612fc55ab1af75afb9577ea0fa Mon Sep 17 00:00:00 2001 From: JimmFly Date: Wed, 25 Oct 2023 16:18:30 +0800 Subject: [PATCH 15/27] feat(core): adapt storage progress to payment system (#4713) --- packages/frontend/component/package.json | 2 + .../setting-components/share.css.ts | 20 +--- .../setting-components/storage-progess.tsx | 97 +++++++++---------- packages/frontend/core/package.json | 2 + .../setting-modal/account-setting/index.tsx | 33 ++++++- .../account-setting/style.css.ts | 4 + yarn.lock | 13 ++- 7 files changed, 99 insertions(+), 72 deletions(-) diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index dcd37e6b1f..bf1d58bc68 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -40,6 +40,7 @@ "@toeverything/infra": "workspace:*", "@toeverything/theme": "^0.7.20", "@vanilla-extract/dynamic": "^2.0.3", + "bytes": "^3.1.2", "check-password-strength": "^2.0.7", "clsx": "^2.0.0", "dayjs": "^1.11.10", @@ -71,6 +72,7 @@ "@storybook/jest": "^0.2.3", "@storybook/testing-library": "^0.2.2", "@testing-library/react": "^14.0.0", + "@types/bytes": "^3.1.3", "@types/react": "^18.2.28", "@types/react-datepicker": "^4.19.0", "@types/react-dnd": "^3.0.2", diff --git a/packages/frontend/component/src/components/setting-components/share.css.ts b/packages/frontend/component/src/components/setting-components/share.css.ts index c5fffdc144..be26abbf75 100644 --- a/packages/frontend/component/src/components/setting-components/share.css.ts +++ b/packages/frontend/component/src/components/setting-components/share.css.ts @@ -115,31 +115,19 @@ globalStyle(`${storageProgressWrapper} .storage-progress-desc`, { globalStyle(`${storageProgressWrapper} .storage-progress-bar-wrapper`, { height: '8px', borderRadius: '4px', - backgroundColor: 'var(--affine-pure-black-10)', + backgroundColor: 'var(--affine-black-10)', overflow: 'hidden', }); export const storageProgressBar = style({ height: '100%', backgroundColor: 'var(--affine-processing-color)', selectors: { - '&.warning': { - // Wait for design - backgroundColor: '#FF7C09', - }, '&.danger': { backgroundColor: 'var(--affine-error-color)', }, }, }); -export const storageExtendHint = style({ - borderRadius: '4px', - padding: '4px 8px', - backgroundColor: 'var(--affine-background-secondary-color)', - color: 'var(--affine-text-secondary-color)', - fontSize: 'var(--affine-font-xs)', - lineHeight: '20px', - marginTop: 8, -}); -globalStyle(`${storageExtendHint} a`, { - color: 'var(--affine-link-color)', + +export const storageButton = style({ + padding: '4px 12px', }); diff --git a/packages/frontend/component/src/components/setting-components/storage-progess.tsx b/packages/frontend/component/src/components/setting-components/storage-progess.tsx index 5968bf29cd..33dd170182 100644 --- a/packages/frontend/component/src/components/setting-components/storage-progess.tsx +++ b/packages/frontend/component/src/components/setting-components/storage-progess.tsx @@ -1,6 +1,7 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { Button } from '@toeverything/components/button'; import { Tooltip } from '@toeverything/components/tooltip'; +import bytes from 'bytes'; import clsx from 'clsx'; import { useMemo } from 'react'; @@ -10,20 +11,14 @@ export interface StorageProgressProgress { max: number; value: number; onUpgrade: () => void; + plan: string; } -const transformBytesToMB = (bytes: number) => { - return (bytes / 1024 / 1024).toFixed(2); -}; - -const transformBytesToGB = (bytes: number) => { - return (bytes / 1024 / 1024 / 1024).toFixed(2); -}; - export const StorageProgress = ({ max: upperLimit, value, onUpgrade, + plan, }: StorageProgressProgress) => { const t = useAFFiNEI18N(); const percent = useMemo( @@ -31,51 +26,53 @@ export const StorageProgress = ({ [upperLimit, value] ); - const used = useMemo(() => transformBytesToMB(value), [value]); - const max = useMemo(() => transformBytesToGB(upperLimit), [upperLimit]); + const used = useMemo(() => bytes.format(value), [value]); + const max = useMemo(() => bytes.format(upperLimit), [upperLimit]); + + const buttonType = useMemo(() => { + if (plan === 'Free') { + return 'primary'; + } + return 'default'; + }, [plan]); return ( - <> -
-
-
- {t['com.affine.storage.used.hint']()} - - {used}MB/{max}GB - -
- -
-
80, - danger: percent > 99, - })} - style={{ width: `${percent}%` }} - >
-
-
- - - - +
+
+
+ {t['com.affine.storage.used.hint']()} + + {used}/{max} + {` (${plan} Plan)`} - -
- {percent > 80 ? ( -
- {t['com.affine.storage.extend.hint']()} - - {t['com.affine.storage.extend.link']()} -
- ) : null} - + +
+
80, + })} + style={{ width: `${percent > 100 ? '100' : percent}%` }} + >
+
+
+ + + + + + +
); }; diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index 2438b74f82..a7d00eaa06 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -43,6 +43,7 @@ "@react-hookz/web": "^23.1.0", "@toeverything/components": "^0.0.45", "async-call-rpc": "^6.3.1", + "bytes": "^3.1.2", "css-spring": "^4.1.0", "cssnano": "^6.0.1", "graphql": "^16.8.1", @@ -75,6 +76,7 @@ "@sentry/webpack-plugin": "^2.8.0", "@svgr/webpack": "^8.1.0", "@swc/core": "^1.3.93", + "@types/bytes": "^3.1.3", "@types/lodash-es": "^4.17.9", "@types/webpack-env": "^1.18.2", "copy-webpack-plugin": "^11.0.0", diff --git a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx index b58fe74b57..8a5541b1a0 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx @@ -7,6 +7,7 @@ import { import { allBlobSizesQuery, removeAvatarMutation, + SubscriptionPlan, uploadAvatarMutation, } from '@affine/graphql'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; @@ -14,17 +15,24 @@ import { useMutation, useQuery } from '@affine/workspace/affine/gql'; import { ArrowRightSmallIcon, CameraIcon } from '@blocksuite/icons'; import { Avatar } from '@toeverything/components/avatar'; import { Button } from '@toeverything/components/button'; +import bytes from 'bytes'; import { useSetAtom } from 'jotai'; import { type FC, type MouseEvent, Suspense, useCallback, + useMemo, useState, } from 'react'; -import { authAtom, openSignOutModalAtom } from '../../../../atoms'; +import { + authAtom, + openSettingModalAtom, + openSignOutModalAtom, +} from '../../../../atoms'; import { useCurrentUser } from '../../../../hooks/affine/use-current-user'; +import { useUserSubscription } from '../../../../hooks/use-subscription'; import { Upload } from '../../../pure/file-upload'; import * as style from './style.css'; @@ -124,6 +132,7 @@ export const AvatarAndName = () => { @@ -208,7 +231,7 @@ export const AccountSetting: FC = () => { name={t['com.affine.settings.password']()} desc={t['com.affine.settings.password.message']()} > -
@@ -59,9 +65,7 @@ export const StorageProgress = ({ diff --git a/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx b/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx index be9e4ca8ae..580db2ea11 100644 --- a/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx +++ b/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx @@ -1,4 +1,5 @@ import { SubscriptionPlan } from '@affine/graphql'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; import Tooltip from '@toeverything/components/tooltip'; import { useSetAtom } from 'jotai'; import { useCallback } from 'react'; @@ -24,8 +25,10 @@ export const UserPlanButton = () => { [setSettingModalAtom] ); + const t = useAFFiNEI18N(); + return ( - +
{plan}
diff --git a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx index 8a5541b1a0..34c50bbf58 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx @@ -157,6 +157,7 @@ const StoragePanel = () => { const [subscription] = useUserSubscription(); const plan = subscription?.plan ?? SubscriptionPlan.Free; + const maxLimit = useMemo(() => { return bytes.parse(plan === SubscriptionPlan.Free ? '10GB' : '100GB'); }, [plan]); 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 146f982470..a1eab2ab5d 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 @@ -15,11 +15,13 @@ import { SubscriptionRecurring, SubscriptionStatus, } from '@affine/graphql'; +import { Trans } from '@affine/i18n'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; 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 } from 'react'; +import { Suspense, useCallback, useMemo } from 'react'; import { openSettingModalAtom } from '../../../../../atoms'; import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status'; @@ -29,8 +31,25 @@ import { } from '../../../../../hooks/use-subscription'; import * as styles from './style.css'; +enum DescriptionI18NKey { + Basic = 'com.affine.payment.billing-setting.current-plan.description', + Monthly = 'com.affine.payment.billing-setting.current-plan.description.monthly', + Yearly = 'com.affine.payment.billing-setting.current-plan.description.yearly', +} + +const getMessageKey = ( + plan: SubscriptionPlan, + recurring: SubscriptionRecurring +): DescriptionI18NKey => { + if (plan !== SubscriptionPlan.Pro) { + return DescriptionI18NKey.Basic; + } + return DescriptionI18NKey[recurring]; +}; + export const BillingSettings = () => { const status = useCurrentLoginStatus(); + const t = useAFFiNEI18N(); if (status !== 'authenticated') { return null; @@ -39,18 +58,22 @@ export const BillingSettings = () => { return ( <> {/* TODO: loading fallback */} - + {/* TODO: loading fallback */} - + @@ -74,75 +97,11 @@ const SubscriptionSettings = () => { : price ? recurring === SubscriptionRecurring.Monthly ? String(price.amount / 100) - : (price.yearlyAmount / 100 / 12).toFixed(2) + : String(price.yearlyAmount / 100) : '?'; - return ( -
-
-
- - You are current on the{' '} - - {/* TODO: Action */} - {plan} plan - - . -

- } - /> - -
-

${amount}/month

-
- {subscription?.status === SubscriptionStatus.Active && ( - <> - - - - {subscription.nextBillAt && ( - - )} - {subscription.canceledAt ? ( - - - - ) : ( - - - - )} - - )} -
- ); -}; + const t = useAFFiNEI18N(); -const PlanAction = ({ plan }: { plan: string }) => { const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom); const gotoPlansSetting = useCallback(() => { @@ -153,13 +112,120 @@ const PlanAction = ({ plan }: { plan: string }) => { }); }, [setOpenSettingModalAtom]); + const currentPlanDesc = useMemo(() => { + const messageKey = getMessageKey(plan, recurring); + return ( + + ), + }} + /> + ); + }, [plan, recurring, gotoPlansSetting]); + + return ( +
+
+
+ + +
+

+ ${amount} + + / + {recurring === SubscriptionRecurring.Monthly + ? t['com.affine.payment.billing-setting.month']() + : t['com.affine.payment.billing-setting.year']()} + +

+
+ {subscription?.status === SubscriptionStatus.Active && ( + <> + + + + {subscription.nextBillAt && ( + + )} + {subscription.canceledAt ? ( + + + + ) : ( + + + + )} + + )} +
+ ); +}; + +const PlanAction = ({ + plan, + gotoPlansSetting, +}: { + plan: string; + gotoPlansSetting: () => void; +}) => { + const t = useAFFiNEI18N(); + return ( ); }; @@ -169,6 +235,7 @@ const PaymentMethodUpdater = () => { const { isMutating, trigger } = useMutation({ mutation: createCustomerPortalMutation, }); + const t = useAFFiNEI18N(); const update = useCallback(() => { trigger(null, { @@ -179,8 +246,13 @@ const PaymentMethodUpdater = () => { }, [trigger]); return ( - ); }; @@ -190,6 +262,7 @@ const ResumeSubscription = ({ }: { onSubscriptionUpdate: SubscriptionMutator; }) => { + const t = useAFFiNEI18N(); const { isMutating, trigger } = useMutation({ mutation: resumeSubscriptionMutation, }); @@ -203,8 +276,13 @@ const ResumeSubscription = ({ }, [trigger, onSubscriptionUpdate]); return ( - ); }; @@ -237,6 +315,7 @@ const CancelSubscription = ({ }; const BillingHistory = () => { + const t = useAFFiNEI18N(); const { data: invoicesQueryResult } = useQuery({ query: invoicesQuery, variables: { @@ -250,7 +329,9 @@ const BillingHistory = () => { return (
{invoices.length === 0 ? ( -

There are no invoices to display.

+

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

) : ( // TODO: pagination invoices.map(invoice => ( @@ -266,21 +347,28 @@ const InvoiceLine = ({ }: { invoice: NonNullable['invoices'][0]; }) => { + const t = useAFFiNEI18N(); + 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 - }`} + desc={`${ + invoice.status === InvoiceStatus.Paid + ? t['com.affine.payment.billing-setting.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 index 74f87f2c5e..55243b36dd 100644 --- 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 @@ -25,6 +25,10 @@ export const planPrice = style({ fontWeight: 600, }); +export const billingFrequency = style({ + fontSize: 'var(--affine-font-base)', +}); + export const paymentMethod = style({ marginTop: '24px', }); @@ -37,3 +41,13 @@ export const noInvoice = style({ color: 'var(--affine-text-secondary-color)', fontSize: 'var(--affine-font-xs)', }); + +export const currentPlanName = style({ + fontSize: 'var(--affine-font-xs)', + fontWeight: 500, + color: 'var(--affine-text-emphasis-color)', + cursor: 'pointer', +}); +export const button = style({ + padding: '4px 12px', +}); 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 97012598b5..241170a1d0 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 @@ -51,8 +51,7 @@ export const useGeneralSettingList = (): GeneralSettingList => { }, { key: 'plans', - // TODO: i18n - title: 'AFFiNE Cloud Plans', + title: t['com.affine.payment.title'](), // TODO: icon icon: KeyboardIcon, testId: 'plans-panel-trigger', @@ -75,8 +74,7 @@ export const useGeneralSettingList = (): GeneralSettingList => { if (status === 'authenticated') { settings.splice(3, 0, { key: 'billing', - // TODO: i18n - title: 'Billing', + title: t['com.affine.payment.billing-setting.title'](), // TODO: icon icon: KeyboardIcon, testId: 'billing-panel-trigger', diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 2cc521d8e2..4ae0bfd809 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -333,7 +333,9 @@ "com.affine.storage.extend.hint": "The usage has reached its maximum capacity, AFFiNE Cloud is currently in early access phase and is not supported for upgrading, please be patient and wait for our pricing plan. ", "com.affine.storage.extend.link": "To get more information click here.", "com.affine.storage.title": "AFFiNE Cloud Storage", - "com.affine.storage.upgrade": "Upgrade to Pro", + "com.affine.storage.upgrade": "Upgrade", + "com.affine.storage.change-plan": "Change", + "com.affine.storage.plan": "Plan", "com.affine.storage.used.hint": "Space used", "com.affine.themeSettings.dark": "Dark", "com.affine.themeSettings.light": "Light", @@ -639,5 +641,32 @@ "com.affine.auth.sign-out.confirm-modal.title": "Sign out?", "com.affine.auth.sign-out.confirm-modal.description": "After signing out, the Cloud Workspaces associated with this account will be removed from the current device, and signing in again will add them back.", "com.affine.auth.sign-out.confirm-modal.cancel": "Cancel", - "com.affine.auth.sign-out.confirm-modal.confirm": "Sign Out" + "com.affine.auth.sign-out.confirm-modal.confirm": "Sign Out", + "com.affine.storage.maximum-tips": "You have reached the maximum capacity limit for your current account", + "com.affine.payment.tag-tooltips": "See all plans", + "com.affine.payment.title": "Pricing Plans", + "com.affine.payment.billing-setting.title": "Billing", + "com.affine.payment.billing-setting.subtitle": "Manage your billing information and invoices.", + "com.affine.payment.billing-setting.information": "Information", + "com.affine.payment.billing-setting.history": "Billing history", + "com.affine.payment.billing-setting.current-plan": "Current Plan", + "com.affine.payment.billing-setting.current-plan.description": "You are current on the <1>{{planName}} plan.", + "com.affine.payment.billing-setting.current-plan.description.monthly": "You are current on the monthly <1>{{planName}} plan.", + "com.affine.payment.billing-setting.current-plan.description.yearly": "You are current on the yearly <1>{{planName}} plan.", + "com.affine.payment.billing-setting.month": "month", + "com.affine.payment.billing-setting.year": "year", + "com.affine.payment.billing-setting.payment-method": "Payment Method", + "com.affine.payment.billing-setting.payment-method.description": "Provided by Stripe.", + "com.affine.payment.billing-setting.renew-date": "Renew Date", + "com.affine.payment.billing-setting.renew-date.description": "Next billing date: {{renewDate}}", + "com.affine.payment.billing-setting.expiration-date": "Expiration Date", + "com.affine.payment.billing-setting.expiration-date.description": "Your subscription is valid until {{expirationDate}}", + "com.affine.payment.billing-setting.cancel-subscription": "Cancel Subscription", + "com.affine.payment.billing-setting.cancel-subscription.description": "Subscription cancelled, your pro account will expire on {{cancelDate}}", + "com.affine.payment.billing-setting.upgrade": "Upgrade", + "com.affine.payment.billing-setting.change-plan": "Change Plan", + "com.affine.payment.billing-setting.resume-subscription": "Resume", + "com.affine.payment.billing-setting.no-invoice": "There are no invoices to display.", + "com.affine.payment.billing-setting.paid": "Paid", + "com.affine.payment.billing-setting.view-invoice": "View Invoice" } From 9334a363c775220d420d7df1f10e419676ad6e60 Mon Sep 17 00:00:00 2001 From: liuyi Date: Thu, 26 Oct 2023 20:37:52 +0800 Subject: [PATCH 18/27] chore(server): upgrade stripe sdk (#4733) --- packages/backend/server/package.json | 2 +- packages/backend/server/src/config/default.ts | 2 +- yarn.lock | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index 2571db2fb4..83f06abc53 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -72,7 +72,7 @@ "rxjs": "^7.8.1", "semver": "^7.5.4", "socket.io": "^4.7.2", - "stripe": "^13.6.0", + "stripe": "^14.1.0", "ws": "^8.14.2", "yjs": "^13.6.8" }, diff --git a/packages/backend/server/src/config/default.ts b/packages/backend/server/src/config/default.ts index ae46469847..d49aad9709 100644 --- a/packages/backend/server/src/config/default.ts +++ b/packages/backend/server/src/config/default.ts @@ -215,7 +215,7 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { APIKey: '', webhookKey: '', }, - apiVersion: '2023-08-16', + apiVersion: '2023-10-16', }, }, } satisfies AFFiNEConfig; diff --git a/yarn.lock b/yarn.lock index 43dd0fd792..395abe1a84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -740,7 +740,7 @@ __metadata: semver: "npm:^7.5.4" sinon: "npm:^16.1.0" socket.io: "npm:^4.7.2" - stripe: "npm:^13.6.0" + stripe: "npm:^14.1.0" supertest: "npm:^6.3.3" ts-node: "npm:^10.9.1" typescript: "npm:^5.2.2" @@ -31981,13 +31981,13 @@ __metadata: languageName: node linkType: hard -"stripe@npm:^13.6.0": - version: 13.6.0 - resolution: "stripe@npm:13.6.0" +"stripe@npm:^14.1.0": + version: 14.1.0 + resolution: "stripe@npm:14.1.0" dependencies: "@types/node": "npm:>=8.1.0" qs: "npm:^6.11.0" - checksum: 3fae1ed3dc845166c36fb28e4297ec770bb1f1b35e88b0166c465a31d41216203341b1055bf63b653fa3c66cd5d2eb72fdfaec9b58a7d467d207645a12b2cde0 + checksum: 1363bc6fed873a9d96312a7d33fb422b9e0ca32064cf027eee12cf5cd132a936d3e248013b59d7011ca5a40acaae9adfcbae175345f7c76ee778800ca1077756 languageName: node linkType: hard From edb6e0fd6905aa461ba5309c3700b0a58f8a8922 Mon Sep 17 00:00:00 2001 From: Cats Juice Date: Thu, 26 Oct 2023 22:00:14 +0800 Subject: [PATCH 19/27] feat(core): pricing plans actions (#4724) --- .../general-setting/plans/index.tsx | 89 +++- .../general-setting/plans/layout.tsx | 32 +- .../general-setting/plans/modals.tsx | 119 ++++++ .../general-setting/plans/plan-card.tsx | 383 +++++++++++++----- .../general-setting/plans/style.css.ts | 33 +- .../components/affine/setting-modal/index.tsx | 66 +-- .../affine/setting-modal/style.css.ts | 18 +- packages/frontend/i18n/src/resources/en.json | 45 +- 8 files changed, 607 insertions(+), 178 deletions(-) create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/modals.tsx 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 6cb38e898c..4067782bd4 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 @@ -1,10 +1,14 @@ import { RadioButton, RadioButtonGroup } from '@affine/component'; +import { pushNotificationAtom } from '@affine/component/notification-center'; import { pricesQuery, SubscriptionPlan, SubscriptionRecurring, } from '@affine/graphql'; +import { Trans } from '@affine/i18n'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useQuery } from '@affine/workspace/affine/gql'; +import { useSetAtom } from 'jotai'; import { Suspense, useEffect, useRef, useState } from 'react'; import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status'; @@ -14,10 +18,25 @@ import { type FixedPrice, getPlanDetail, PlanCard } from './plan-card'; import { PlansSkeleton } from './skeleton'; import * as styles from './style.css'; +const getRecurringLabel = ({ + recurring, + t, +}: { + recurring: SubscriptionRecurring; + t: ReturnType; +}) => { + return recurring === SubscriptionRecurring.Monthly + ? t['com.affine.payment.recurring-monthly']() + : t['com.affine.payment.recurring-yearly'](); +}; + const Settings = () => { + const t = useAFFiNEI18N(); const [subscription, mutateSubscription] = useUserSubscription(); + const pushNotification = useSetAtom(pushNotificationAtom); + const loggedIn = useCurrentLoginStatus() === 'authenticated'; - const planDetail = getPlanDetail(); + const planDetail = getPlanDetail(t); const scrollWrapper = useRef(null); const { @@ -44,6 +63,9 @@ const Settings = () => { ); const currentPlan = subscription?.plan ?? SubscriptionPlan.Free; + const isCanceled = !!subscription?.canceledAt; + const currentRecurring = + subscription?.recurring ?? SubscriptionRecurring.Monthly; const yearlyDiscount = ( planDetail.get(SubscriptionPlan.Pro) as FixedPrice | undefined @@ -76,23 +98,39 @@ const Settings = () => { }, [recurring]); const subtitle = loggedIn ? ( -

- You are current on the {currentPlan} plan. If you have any questions, - please contact our {/*TODO: add action*/}customer support. -

+ isCanceled ? ( +

+ {t['com.affine.payment.subtitle-canceled']({ + plan: `${getRecurringLabel({ + recurring: currentRecurring, + t, + })} ${currentPlan}`, + })} +

+ ) : ( +

+ + You are current on the {{ currentPlan }} plan. If you have any + questions, please contact our  + + customer support + + . + +

+ ) ) : ( -

- This is the Pricing plans of AFFiNE Cloud. You can sign up or sign in to - your account first. -

+

{t['com.affine.payment.subtitle-not-signed-in']()}

); - const getRecurringLabel = (recurring: SubscriptionRecurring) => - ({ - [SubscriptionRecurring.Monthly]: 'Monthly', - [SubscriptionRecurring.Yearly]: 'Annually', - })[recurring]; - const tabs = ( { > {Object.values(SubscriptionRecurring).map(recurring => ( - {getRecurringLabel(recurring)} + {getRecurringLabel({ recurring, t })} {recurring === SubscriptionRecurring.Yearly && yearlyDiscount && ( - {yearlyDiscount}% off + {t['com.affine.payment.discount-amount']({ + amount: yearlyDiscount, + })} )} @@ -119,6 +159,21 @@ const Settings = () => { { + pushNotification({ + type: 'success', + title: t['com.affine.payment.updated-notify-title'](), + message: t['com.affine.payment.updated-notify-msg']({ + plan: + detail.plan === SubscriptionPlan.Free + ? SubscriptionPlan.Free + : getRecurringLabel({ + recurring: recurring as SubscriptionRecurring, + t, + }), + }), + }); + }} {...{ detail, subscription, recurring }} /> ); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx index 424e944cd0..89388f4066 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx @@ -1,4 +1,5 @@ import { SettingHeader } from '@affine/component/setting-components'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { ArrowRightBigIcon } from '@blocksuite/icons'; import type { HtmlHTMLAttributes, ReactNode } from 'react'; @@ -14,32 +15,37 @@ export interface PlanLayoutProps scrollRef?: React.RefObject; } -const SeeAllLink = () => ( - - See all plans - {} - -); +const SeeAllLink = () => { + const t = useAFFiNEI18N(); + + return ( + + {t['com.affine.payment.see-all-plans']()} + {} + + ); +}; export const PlanLayout = ({ subtitle, tabs, scroll, - title = 'Pricing Plans', + title, footer = , scrollRef, }: PlanLayoutProps) => { + const t = useAFFiNEI18N(); return (
{/* TODO: SettingHeader component shouldn't have margin itself */} {tabs} diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/modals.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/modals.tsx new file mode 100644 index 0000000000..cdd208228e --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/modals.tsx @@ -0,0 +1,119 @@ +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { DialogTrigger } from '@radix-ui/react-dialog'; +import { Button } from '@toeverything/components/button'; +import { + ConfirmModal, + type ConfirmModalProps, + Modal, +} from '@toeverything/components/modal'; +import { type ReactNode, useEffect, useRef } from 'react'; + +import * as styles from './style.css'; + +/** + * + * @param param0 + * @returns + */ +export const ConfirmLoadingModal = ({ + type, + loading, + open, + content, + onOpenChange, + onConfirm, + ...props +}: { + type: 'resume' | 'change'; + loading?: boolean; + content?: ReactNode; +} & ConfirmModalProps) => { + const t = useAFFiNEI18N(); + const confirmed = useRef(false); + + const title = t[`com.affine.payment.modal.${type}.title`](); + const confirmText = t[`com.affine.payment.modal.${type}.confirm`](); + const cancelText = t[`com.affine.payment.modal.${type}.cancel`](); + const contentText = + type !== 'change' ? t[`com.affine.payment.modal.${type}.content`]() : ''; + + useEffect(() => { + if (!loading && open && confirmed.current) { + onOpenChange?.(false); + confirmed.current = false; + } + }, [loading, open, onOpenChange]); + + return ( + { + confirmed.current = true; + onConfirm?.(); + }} + {...props} + > + {content ?? contentText} + + ); +}; + +/** + * Downgrade modal, confirm & cancel button are reversed + * @param param0 + */ +export const DowngradeModal = ({ + open, + onOpenChange, + onCancel, +}: { + loading?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + onCancel?: () => void; +}) => { + const t = useAFFiNEI18N(); + + return ( + +
+

+ {t['com.affine.payment.modal.downgrade.content']()} +

+

+ {t['com.affine.payment.modal.downgrade.caption']()} +

+
+ +
+ + + + +
+
+ ); +}; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx index 45c90ab68b..58b32f61c3 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx @@ -5,19 +5,32 @@ import type { import { cancelSubscriptionMutation, checkoutMutation, + resumeSubscriptionMutation, SubscriptionPlan, SubscriptionRecurring, updateSubscriptionMutation, } from '@affine/graphql'; +import { Trans } from '@affine/i18n'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useMutation } from '@affine/workspace/affine/gql'; import { DoneIcon } from '@blocksuite/icons'; import { Button } from '@toeverything/components/button'; +import { Tooltip } from '@toeverything/components/tooltip'; +import { useSetAtom } from 'jotai'; import { useAtom } from 'jotai'; -import { type PropsWithChildren, useCallback, useEffect, useRef } from 'react'; +import { + type PropsWithChildren, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import { openPaymentDisableAtom } from '../../../../../atoms'; +import { authAtom } from '../../../../../atoms/index'; import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status'; import { BulledListIcon } from './icons/bulled-list'; +import { ConfirmLoadingModal, DowngradeModal } from './modals'; import * as styles from './style.css'; export interface FixedPrice { @@ -41,12 +54,13 @@ interface PlanCardProps { subscription?: Subscription | null; recurring: string; onSubscriptionUpdate: SubscriptionMutator; + onNotify: (info: { + detail: FixedPrice | DynamicPrice; + recurring: string; + }) => void; } -export function getPlanDetail() { - // const t = useAFFiNEI18N(); - - // TODO: i18n all things +export function getPlanDetail(t: ReturnType) { return new Map([ [ SubscriptionPlan.Free, @@ -56,12 +70,12 @@ export function getPlanDetail() { price: '0', yearlyPrice: '0', benefits: [ - 'Unlimited local workspace', - 'Unlimited login devices', - 'Unlimited blocks', - 'AFFiNE Cloud Storage 10GB', - 'The maximum file size is 10M', - 'Number of members per Workspace ≤ 3', + t['com.affine.payment.benefit-1'](), + t['com.affine.payment.benefit-2'](), + t['com.affine.payment.benefit-3'](), + t['com.affine.payment.benefit-4']({ capacity: '10GB' }), + t['com.affine.payment.benefit-5']({ capacity: '10M' }), + t['com.affine.payment.benefit-6']({ capacity: '3' }), ], }, ], @@ -73,12 +87,12 @@ export function getPlanDetail() { price: '1', yearlyPrice: '1', benefits: [ - 'Unlimited local workspace', - 'Unlimited login devices', - 'Unlimited blocks', - 'AFFiNE Cloud Storage 100GB', - 'The maximum file size is 500M', - 'Number of members per Workspace ≤ 10', + t['com.affine.payment.benefit-1'](), + t['com.affine.payment.benefit-2'](), + t['com.affine.payment.benefit-3'](), + t['com.affine.payment.benefit-4']({ capacity: '100GB' }), + t['com.affine.payment.benefit-5']({ capacity: '500M' }), + t['com.affine.payment.benefit-6']({ capacity: '10' }), ], }, ], @@ -89,9 +103,9 @@ export function getPlanDetail() { plan: SubscriptionPlan.Team, contact: true, benefits: [ - 'Best team workspace for collaboration and knowledge distilling.', - 'Focusing on what really matters with team project management and automation.', - 'Pay for seats, fits all team size.', + t['com.affine.payment.dynamic-benefit-1'](), + t['com.affine.payment.dynamic-benefit-2'](), + t['com.affine.payment.dynamic-benefit-3'](), ], }, ], @@ -102,20 +116,16 @@ export function getPlanDetail() { plan: SubscriptionPlan.Enterprise, contact: true, benefits: [ - 'Solutions & best practices for dedicated needs.', - 'Embedable & interrogations with IT support.', + t['com.affine.payment.dynamic-benefit-4'](), + t['com.affine.payment.dynamic-benefit-5'](), ], }, ], ]); } -export const PlanCard = ({ - detail, - subscription, - recurring, - onSubscriptionUpdate, -}: PlanCardProps) => { +export const PlanCard = (props: PlanCardProps) => { + const { detail, subscription, recurring } = props; const loggedIn = useCurrentLoginStatus() === 'authenticated'; const currentPlan = subscription?.plan ?? SubscriptionPlan.Free; const currentRecurring = subscription?.recurring; @@ -160,49 +170,7 @@ export const PlanCard = ({ )}

- { - // branches: - // if contact => 'Contact Sales' - // if not signed in: - // if free => 'Sign up free' - // else => 'Buy Pro' - // else - // if isCurrent => 'Current Plan' - // else if free => 'Downgrade' - // else if currentRecurring !== recurring => 'Change to {recurring} Billing' - // else => 'Upgrade' - // TODO: should replace with components with proper actions - detail.type === 'dynamic' ? ( - - ) : loggedIn ? ( - detail.plan === currentPlan && - (currentRecurring === recurring || - (!currentRecurring && detail.plan === SubscriptionPlan.Free)) ? ( - - ) : detail.plan === SubscriptionPlan.Free ? ( - - ) : currentRecurring !== recurring && - currentPlan === detail.plan ? ( - - ) : ( - - ) - ) : ( - - {detail.plan === SubscriptionPlan.Free - ? 'Sign up free' - : 'Buy Pro'} - - ) - } +
{detail.benefits.map((content, i) => ( @@ -226,15 +194,111 @@ export const PlanCard = ({ ); }; +const ActionButton = ({ + detail, + subscription, + recurring, + onSubscriptionUpdate, + onNotify, +}: PlanCardProps) => { + const t = useAFFiNEI18N(); + const loggedIn = useCurrentLoginStatus() === 'authenticated'; + const currentPlan = subscription?.plan ?? SubscriptionPlan.Free; + const currentRecurring = subscription?.recurring; + + const mutateAndNotify = useCallback( + (sub: Parameters[0]) => { + onSubscriptionUpdate?.(sub); + onNotify?.({ detail, recurring }); + }, + [onSubscriptionUpdate, onNotify, detail, recurring] + ); + + // branches: + // if contact => 'Contact Sales' + // if not signed in: + // if free => 'Sign up free' + // else => 'Buy Pro' + // else + // if isCurrent + // if canceled => 'Resume' + // else => 'Current Plan' + // if isCurrent => 'Current Plan' + // else if free => 'Downgrade' + // else if currentRecurring !== recurring => 'Change to {recurring} Billing' + // else => 'Upgrade' + + // contact + if (detail.type === 'dynamic') { + return ; + } + + // not signed in + if (!loggedIn) { + return ( + + {detail.plan === SubscriptionPlan.Free + ? t['com.affine.payment.sign-up-free']() + : t['com.affine.payment.buy-pro']()} + + ); + } + + const isCanceled = !!subscription?.canceledAt; + const isFree = detail.plan === SubscriptionPlan.Free; + const isCurrent = + detail.plan === currentPlan && + (isFree ? true : currentRecurring === recurring); + + // is current + if (isCurrent) { + return isCanceled ? ( + + ) : ( + + ); + } + + if (isFree) { + return ( + + ); + } + + return currentPlan === detail.plan ? ( + + ) : ( + + ); +}; + const CurrentPlan = () => { - return ; + const t = useAFFiNEI18N(); + return ( + + ); }; const Downgrade = ({ + disabled, onSubscriptionUpdate, }: { + disabled?: boolean; onSubscriptionUpdate: SubscriptionMutator; }) => { + const t = useAFFiNEI18N(); + const [open, setOpen] = useState(false); const { isMutating, trigger } = useMutation({ mutation: cancelSubscriptionMutation, }); @@ -247,25 +311,43 @@ const Downgrade = ({ }); }, [trigger, onSubscriptionUpdate]); + const tooltipContent = disabled + ? t['com.affine.payment.downgraded-tooltip']() + : null; + return ( - + <> + +
+ +
+
+ + ); }; const ContactSales = () => { + const t = useAFFiNEI18N(); return ( - // TODO: add action - + + + ); }; @@ -276,6 +358,7 @@ const Upgrade = ({ recurring: SubscriptionRecurring; onSubscriptionUpdate: SubscriptionMutator; }) => { + const t = useAFFiNEI18N(); const { isMutating, trigger } = useMutation({ mutation: checkoutMutation, }); @@ -330,27 +413,35 @@ const Upgrade = ({ }, [onClose]); return ( - + <> + + ); }; const ChangeRecurring = ({ - from: _from /* TODO: from can be useful when showing confirmation modal */, + from, to, + disabled, + due, onSubscriptionUpdate, }: { from: SubscriptionRecurring; to: SubscriptionRecurring; + disabled?: boolean; + due: string; onSubscriptionUpdate: SubscriptionMutator; }) => { + const t = useAFFiNEI18N(); + const [open, setOpen] = useState(false); const { isMutating, trigger } = useMutation({ mutation: updateSubscriptionMutation, }); @@ -366,24 +457,102 @@ const ChangeRecurring = ({ ); }, [trigger, onSubscriptionUpdate, to]); + const changeCurringContent = ( + + You are changing your {from}{' '} + subscription to {to}{' '} + subscription. This change will take effect in the next billing cycle, with + an effective date of {due}. + + ); + return ( - + <> + + + + ); }; -const SignupAction = ({ children }: PropsWithChildren) => { - // TODO: add login action +const SignUpAction = ({ children }: PropsWithChildren) => { + const setOpen = useSetAtom(authAtom); + + const onClickSignIn = useCallback(async () => { + setOpen(state => ({ + ...state, + openModal: true, + })); + }, [setOpen]); + return ( - ); }; + +const ResumeAction = ({ + onSubscriptionUpdate, +}: { + onSubscriptionUpdate: SubscriptionMutator; +}) => { + const t = useAFFiNEI18N(); + const [open, setOpen] = useState(false); + const [hovered, setHovered] = useState(false); + const { isMutating, trigger } = useMutation({ + mutation: resumeSubscriptionMutation, + }); + + const resume = useCallback(() => { + trigger(null, { + onSuccess: data => { + onSubscriptionUpdate(data.resumeSubscription); + }, + }); + }, [trigger, onSubscriptionUpdate]); + + return ( + <> + + + + + ); +}; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts index 4189024ff9..a1c5ac5abb 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts @@ -14,7 +14,7 @@ export const radioButtonDiscount = style({ }); export const planCardsWrapper = style({ - paddingRight: 'calc(var(--setting-modal-gap-x))', + paddingRight: 'calc(var(--setting-modal-gap-x) + 30px)', display: 'flex', gap: '16px', width: 'fit-content', @@ -116,3 +116,34 @@ export const planBenefitText = style({ flexDirection: 'column', alignItems: 'center', }); + +export const downgradeContentWrapper = style({ + padding: '12px 0 20px 0px', + display: 'flex', + flexDirection: 'column', + gap: '12px', +}); + +export const downgradeContent = style({ + fontSize: '15px', + lineHeight: '24px', + fontWeight: 400, + color: 'var(--affine-text-primary-color)', +}); + +export const downgradeCaption = style({ + fontSize: '14px', + lineHeight: '22px', + color: 'var(--affine-text-secondary-color)', +}); + +export const downgradeFooter = style({ + display: 'flex', + justifyContent: 'flex-end', + gap: '20px', + paddingTop: '20px', +}); + +export const textEmphasis = style({ + color: 'var(--affine-text-emphasis-color)', +}); diff --git a/packages/frontend/core/src/components/affine/setting-modal/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/index.tsx index dbac104bd7..7ed213a44e 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/index.tsx @@ -39,6 +39,7 @@ export const SettingModal = ({ const generalSettingList = useGeneralSettingList(); const modalContentRef = useRef(null); + const modalContentWrapperRef = useRef(null); useEffect(() => { if (!modalProps.open) return; @@ -46,19 +47,18 @@ export const SettingModal = ({ const onResize = debounce(() => { cancelAnimationFrame(animationFrameId); animationFrameId = requestAnimationFrame(() => { - if (!modalContentRef.current) return; + if (!modalContentRef.current || !modalContentWrapperRef.current) return; + const wrapperWidth = modalContentWrapperRef.current.offsetWidth; const contentWidth = modalContentRef.current.offsetWidth; - const computedStyle = window.getComputedStyle(modalContentRef.current); - const marginX = parseInt(computedStyle.marginLeft, 10); - const paddingX = parseInt(computedStyle.paddingLeft, 10); + modalContentRef.current?.style.setProperty( '--setting-modal-width', - `${contentWidth + marginX * 2}px` + `${wrapperWidth}px` ); modalContentRef.current?.style.setProperty( '--setting-modal-gap-x', - `${marginX + paddingX}px` + `${(wrapperWidth - contentWidth) / 2}px` ); }); }, 200); @@ -121,33 +121,35 @@ export const SettingModal = ({
-
- {activeTab === 'workspace' && workspaceId ? ( - }> - - - ) : null} - {generalSettingList.find(v => v.key === activeTab) ? ( - - ) : null} - {activeTab === 'account' && loginStatus === 'authenticated' ? ( - - ) : null} -
-
- - - - - {t['com.affine.settings.suggestion']()} - +
+
+ {activeTab === 'workspace' && workspaceId ? ( + }> + + + ) : null} + {generalSettingList.find(v => v.key === activeTab) ? ( + + ) : null} + {activeTab === 'account' && loginStatus === 'authenticated' ? ( + + ) : null} +
+
diff --git a/packages/frontend/core/src/components/affine/setting-modal/style.css.ts b/packages/frontend/core/src/components/affine/setting-modal/style.css.ts index 9ba1ab1c48..786f42111e 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/style.css.ts +++ b/packages/frontend/core/src/components/affine/setting-modal/style.css.ts @@ -3,21 +3,25 @@ import { style } from '@vanilla-extract/css'; export const wrapper = style({ flexGrow: '1', height: '100%', - maxWidth: '560px', - margin: '0 auto', - padding: '40px 15px 20px 15px', - // children + // margin: '0 auto', + padding: '40px 15px 20px 15px', + overflowX: 'hidden', + overflowY: 'auto', + display: 'flex', - flexDirection: 'column', - justifyContent: 'space-between', - alignItems: 'center', + justifyContent: 'center', '::-webkit-scrollbar': { display: 'none', }, }); +export const centerContainer = style({ + width: '100%', + maxWidth: '560px', +}); + export const content = style({ width: '100%', marginBottom: '24px', diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 4ae0bfd809..d8b0c50c4d 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -642,9 +642,52 @@ "com.affine.auth.sign-out.confirm-modal.description": "After signing out, the Cloud Workspaces associated with this account will be removed from the current device, and signing in again will add them back.", "com.affine.auth.sign-out.confirm-modal.cancel": "Cancel", "com.affine.auth.sign-out.confirm-modal.confirm": "Sign Out", + "com.affine.payment.recurring-yearly": "Annually", + "com.affine.payment.recurring-monthly": "Monthly", + "com.affine.payment.title": "Pricing Plans", + "com.affine.payment.subtitle-not-signed-in": "This is the Pricing plans of AFFiNE Cloud. You can sign up or sign in to your account first.", + "com.affine.payment.subtitle-active": "You are current on the {{currentPlan}} plan. If you have any questions, please contact our <3>customer support.", + "com.affine.payment.subtitle-canceled": "You are currently on the {{plan}} plan. After the current billing period ends, your account will automatically switch to the Free plan.", + "com.affine.payment.discount-amount": "{{amount}}% off", + "com.affine.payment.sign-up-free": "Sign up free", + "com.affine.payment.buy-pro": "Buy Pro", + "com.affine.payment.current-plan": "Current Plan", + "com.affine.payment.downgrade": "Downgrade", + "com.affine.payment.upgrade": "Upgrade", + "com.affine.payment.downgraded-tooltip": "You have successfully downgraded. After the current billing period ends, your account will automatically switch to the Free plan.", + "com.affine.payment.contact-sales": "Contact Sales", + "com.affine.payment.change-to": "Change to {{to}} Billing", + "com.affine.payment.resume": "Resume", + "com.affine.payment.resume-renewal": "Resume Auto-renewal", + "com.affine.payment.benefit-1": "Unlimited local workspace", + "com.affine.payment.benefit-2": "Unlimited login devices", + "com.affine.payment.benefit-3": "Unlimited blocks", + "com.affine.payment.benefit-4": "AFFiNE Cloud Storage {{capacity}}", + "com.affine.payment.benefit-5": "The maximum file size is {{capacity}}", + "com.affine.payment.benefit-6": "Number of members per Workspace ≤ {{capacity}}", + "com.affine.payment.dynamic-benefit-1": "Best team workspace for collaboration and knowledge distilling.", + "com.affine.payment.dynamic-benefit-2": "Focusing on what really matters with team project management and automation.", + "com.affine.payment.dynamic-benefit-3": "Pay for seats, fits all team size.", + "com.affine.payment.dynamic-benefit-4": "Solutions & best practices for dedicated needs.", + "com.affine.payment.dynamic-benefit-5": "Embedable & interrogations with IT support.", + "com.affine.payment.see-all-plans": "See all plans", + "com.affine.payment.modal.resume.title": "Resume Auto-Renewal?", + "com.affine.payment.modal.resume.content": "Are you sure you want to resume the subscription for your pro account? This means your payment method will be charged automatically at the end of each billing cycle, starting from the next billing cycle.", + "com.affine.payment.modal.resume.cancel": "Cancel", + "com.affine.payment.modal.resume.confirm": "Confirm", + "com.affine.payment.modal.downgrade.title": "Are you sure?", + "com.affine.payment.modal.downgrade.content": "We're sorry to see you go, but we're always working to improve, and your feedback is welcome. We hope to see you return in the future.", + "com.affine.payment.modal.downgrade.caption": "You can still use AFFiNE Cloud Pro until the end of this billing period :)", + "com.affine.payment.modal.downgrade.cancel": "Cancel Subscription", + "com.affine.payment.modal.downgrade.confirm": "Keep AFFiNE Cloud Pro", + "com.affine.payment.modal.change.title": "Change your subscription", + "com.affine.payment.modal.change.content": "You are changing your <0>from subscription to <1>to subscription. This change will take effect in the next billing cycle, with an effective date of <2>due.", + "com.affine.payment.modal.change.cancel": "Cancel", + "com.affine.payment.modal.change.confirm": "Change", + "com.affine.payment.updated-notify-title": "Subscription updated", + "com.affine.payment.updated-notify-msg": "You have changed your plan to {{plan}} billing.", "com.affine.storage.maximum-tips": "You have reached the maximum capacity limit for your current account", "com.affine.payment.tag-tooltips": "See all plans", - "com.affine.payment.title": "Pricing Plans", "com.affine.payment.billing-setting.title": "Billing", "com.affine.payment.billing-setting.subtitle": "Manage your billing information and invoices.", "com.affine.payment.billing-setting.information": "Information", From 50563dcb6ea8a5bdd42dfe2c049caab9985ec777 Mon Sep 17 00:00:00 2001 From: liuyi Date: Fri, 27 Oct 2023 10:28:22 +0800 Subject: [PATCH 20/27] feat(server): auto attach early access coupon (#4728) --- .../server/src/modules/payment/index.ts | 4 + .../server/src/modules/payment/schedule.ts | 214 +++++++++++++++ .../server/src/modules/payment/service.ts | 254 +++++------------- .../backend/server/src/modules/users/index.ts | 1 + .../backend/server/src/modules/users/users.ts | 15 +- 5 files changed, 302 insertions(+), 186 deletions(-) create mode 100644 packages/backend/server/src/modules/payment/schedule.ts diff --git a/packages/backend/server/src/modules/payment/index.ts b/packages/backend/server/src/modules/payment/index.ts index 1a51678848..30393504b5 100644 --- a/packages/backend/server/src/modules/payment/index.ts +++ b/packages/backend/server/src/modules/payment/index.ts @@ -1,12 +1,16 @@ import { Module } from '@nestjs/common'; +import { UsersModule } from '../users'; import { SubscriptionResolver, UserSubscriptionResolver } from './resolver'; +import { ScheduleManager } from './schedule'; import { SubscriptionService } from './service'; import { StripeProvider } from './stripe'; import { StripeWebhook } from './webhook'; @Module({ + imports: [UsersModule], providers: [ + ScheduleManager, StripeProvider, SubscriptionService, SubscriptionResolver, diff --git a/packages/backend/server/src/modules/payment/schedule.ts b/packages/backend/server/src/modules/payment/schedule.ts new file mode 100644 index 0000000000..423bdf6916 --- /dev/null +++ b/packages/backend/server/src/modules/payment/schedule.ts @@ -0,0 +1,214 @@ +import { Injectable } from '@nestjs/common'; +import Stripe from 'stripe'; + +@Injectable() +export class ScheduleManager { + private _schedule: Stripe.SubscriptionSchedule | null = null; + + constructor(private readonly stripe: Stripe) {} + + static create(stripe: Stripe, schedule?: Stripe.SubscriptionSchedule) { + const manager = new ScheduleManager(stripe); + if (schedule) { + manager._schedule = schedule; + } + + return manager; + } + + get schedule() { + return this._schedule; + } + + get currentPhase() { + if (!this._schedule) { + return null; + } + + return this._schedule.phases.find( + phase => + phase.start_date * 1000 < Date.now() && + phase.end_date * 1000 > Date.now() + ); + } + + get nextPhase() { + if (!this._schedule) { + return null; + } + + return this._schedule.phases.find( + phase => phase.start_date * 1000 > Date.now() + ); + } + + get isActive() { + return this._schedule?.status === 'active'; + } + + async fromSchedule(schedule: string | Stripe.SubscriptionSchedule) { + if (typeof schedule === 'string') { + const s = await this.stripe.subscriptionSchedules + .retrieve(schedule) + .catch(() => undefined); + + return ScheduleManager.create(this.stripe, s); + } else { + return ScheduleManager.create(this.stripe, schedule); + } + } + + async fromSubscription(subscription: string | Stripe.Subscription) { + if (typeof subscription === 'string') { + subscription = await this.stripe.subscriptions.retrieve(subscription, { + expand: ['schedule'], + }); + } + + if (subscription.schedule) { + return await this.fromSchedule(subscription.schedule); + } else { + const schedule = await this.stripe.subscriptionSchedules.create({ + from_subscription: subscription.id, + }); + + return await this.fromSchedule(schedule); + } + } + + /** + * Cancel a subscription by marking schedule's end behavior to `cancel`. + * At the same time, the coming phase's price and coupon will be saved to metadata for later resuming to correction subscription. + */ + async cancel() { + if (!this._schedule) { + throw new Error('No schedule'); + } + + if (!this.isActive || !this.currentPhase) { + throw new Error('Unexpected subscription schedule status'); + } + + const phases: Stripe.SubscriptionScheduleUpdateParams.Phase = { + items: [ + { + price: this.currentPhase.items[0].price as string, + quantity: 1, + }, + ], + coupon: (this.currentPhase.coupon as string | null) ?? undefined, + start_date: this.currentPhase.start_date, + end_date: this.currentPhase.end_date, + }; + + if (this.nextPhase) { + // cancel a subscription with a schedule exiting will delete the upcoming phase, + // it's hard to recover the subscription to the original state if user wan't to resume before due. + // so we manually save the next phase's key information to metadata for later easy resuming. + phases.metadata = { + next_coupon: (this.nextPhase.coupon as string | null) || null, // avoid empty string + next_price: this.nextPhase.items[0].price as string, + }; + } + + await this.stripe.subscriptionSchedules.update(this._schedule.id, { + phases: [phases], + end_behavior: 'cancel', + }); + } + + async resume() { + if (!this._schedule) { + throw new Error('No schedule'); + } + + if (!this.isActive || !this.currentPhase) { + throw new Error('Unexpected subscription schedule status'); + } + + const phases: Stripe.SubscriptionScheduleUpdateParams.Phase[] = [ + { + items: [ + { + price: this.currentPhase.items[0].price as string, + quantity: 1, + }, + ], + coupon: (this.currentPhase.coupon as string | null) ?? undefined, + start_date: this.currentPhase.start_date, + end_date: this.currentPhase.end_date, + metadata: { + next_coupon: null, + next_price: null, + }, + }, + ]; + + if (this.currentPhase.metadata && this.currentPhase.metadata.next_price) { + phases.push({ + items: [ + { + price: this.currentPhase.metadata.next_price, + quantity: 1, + }, + ], + coupon: this.currentPhase.metadata.next_coupon || undefined, + }); + } + + await this.stripe.subscriptionSchedules.update(this._schedule.id, { + phases: phases, + end_behavior: 'release', + }); + } + + async release() { + if (!this._schedule) { + throw new Error('No schedule'); + } + + await this.stripe.subscriptionSchedules.release(this._schedule.id); + } + + async update(price: string, coupon?: string) { + if (!this._schedule) { + throw new Error('No schedule'); + } + + if (!this.isActive || !this.currentPhase) { + throw new Error('Unexpected subscription schedule status'); + } + + // if current phase's plan matches target, and no coupon change, just release the schedule + if ( + this.currentPhase.items[0].price === price && + (!coupon || this.currentPhase.coupon === coupon) + ) { + await this.stripe.subscriptionSchedules.release(this._schedule.id); + this._schedule = null; + } else { + await this.stripe.subscriptionSchedules.update(this._schedule.id, { + phases: [ + { + items: [ + { + price: this.currentPhase.items[0].price as string, + }, + ], + start_date: this.currentPhase.start_date, + end_date: this.currentPhase.end_date, + }, + { + items: [ + { + price: price, + quantity: 1, + }, + ], + coupon, + }, + ], + }); + } + } +} diff --git a/packages/backend/server/src/modules/payment/service.ts b/packages/backend/server/src/modules/payment/service.ts index e9e4374b0b..91d98d2788 100644 --- a/packages/backend/server/src/modules/payment/service.ts +++ b/packages/backend/server/src/modules/payment/service.ts @@ -11,6 +11,8 @@ import Stripe from 'stripe'; import { Config } from '../../config'; import { PrismaService } from '../../prisma'; +import { UsersService } from '../users'; +import { ScheduleManager } from './schedule'; const OnEvent = ( event: Stripe.Event.Type, @@ -65,7 +67,7 @@ export enum InvoiceStatus { Uncollectible = 'uncollectible', } -export enum Coupon { +export enum CouponType { EarlyAccess = 'earlyaccess', EarlyAccessRenew = 'earlyaccessrenew', } @@ -78,7 +80,9 @@ export class SubscriptionService { constructor( config: Config, private readonly stripe: Stripe, - private readonly db: PrismaService + private readonly db: PrismaService, + private readonly user: UsersService, + private readonly scheduleManager: ScheduleManager ) { this.paymentConfig = config.payment; @@ -117,8 +121,9 @@ export class SubscriptionService { } const price = await this.getPrice(plan, recurring); - const customer = await this.getOrCreateCustomer(user); + const coupon = await this.getAvailableCoupon(user, CouponType.EarlyAccess); + return await this.stripe.checkout.sessions.create({ line_items: [ { @@ -126,10 +131,16 @@ export class SubscriptionService { quantity: 1, }, ], - allow_promotion_codes: true, tax_id_collection: { enabled: true, }, + ...(coupon + ? { + discounts: [{ coupon }], + } + : { + allow_promotion_codes: true, + }), mode: 'subscription', success_url: redirectUrl, customer: customer.stripeCustomerId, @@ -160,12 +171,16 @@ export class SubscriptionService { // should release the schedule first if (user.subscription.stripeScheduleId) { - await this.cancelSubscriptionSchedule(user.subscription.stripeScheduleId); + const manager = await this.scheduleManager.fromSchedule( + user.subscription.stripeScheduleId + ); + await manager.cancel(); return this.saveSubscription( user, await this.stripe.subscriptions.retrieve( user.subscription.stripeSubscriptionId - ) + ), + false ); } else { // let customer contact support if they want to cancel immediately @@ -203,12 +218,16 @@ export class SubscriptionService { } if (user.subscription.stripeScheduleId) { - await this.resumeSubscriptionSchedule(user.subscription.stripeScheduleId); + const manager = await this.scheduleManager.fromSchedule( + user.subscription.stripeScheduleId + ); + await manager.resume(); return this.saveSubscription( user, await this.stripe.subscriptions.retrieve( user.subscription.stripeSubscriptionId - ) + ), + false ); } else { const subscription = await this.stripe.subscriptions.update( @@ -252,27 +271,26 @@ export class SubscriptionService { recurring ); - let scheduleId: string | null; - // a schedule existing - if (user.subscription.stripeScheduleId) { - scheduleId = await this.scheduleNewPrice( - user.subscription.stripeScheduleId, - price - ); - } else { - const schedule = await this.stripe.subscriptionSchedules.create({ - from_subscription: user.subscription.stripeSubscriptionId, - }); - await this.scheduleNewPrice(schedule.id, price); - scheduleId = schedule.id; - } + const manager = await this.scheduleManager.fromSubscription( + user.subscription.stripeSubscriptionId + ); + + await manager.update( + price, + // if user is early access user, use early access coupon + manager.currentPhase?.coupon === CouponType.EarlyAccess || + manager.currentPhase?.coupon === CouponType.EarlyAccessRenew || + manager.nextPhase?.coupon === CouponType.EarlyAccessRenew + ? CouponType.EarlyAccessRenew + : undefined + ); return await this.db.userSubscription.update({ where: { id: user.subscription.id, }, data: { - stripeScheduleId: scheduleId, + stripeScheduleId: manager.schedule?.id ?? null, // update schedule id or set to null(undefined means untouched) recurring, }, }); @@ -325,8 +343,26 @@ export class SubscriptionService { }); } - @OnEvent('invoice.created') @OnEvent('invoice.paid') + async onInvoicePaid(stripeInvoice: Stripe.Invoice) { + await this.saveInvoice(stripeInvoice); + + const line = stripeInvoice.lines.data[0]; + + if (!line.price || line.price.type !== 'recurring') { + throw new Error('Unknown invoice with no recurring price'); + } + + // deal with early access user + if (stripeInvoice.discount?.coupon.id === CouponType.EarlyAccess) { + const manager = await this.scheduleManager.fromSubscription( + line.subscription as string + ); + await manager.update(line.price.id, CouponType.EarlyAccessRenew); + } + } + + @OnEvent('invoice.created') @OnEvent('invoice.finalization_failed') @OnEvent('invoice.payment_failed') async saveInvoice(stripeInvoice: Stripe.Invoice) { @@ -591,165 +627,21 @@ export class SubscriptionService { return prices.data[0].id; } - /** - * If a subscription is managed by a schedule, it has a different way to cancel. - */ - private async cancelSubscriptionSchedule(scheduleId: string) { - const schedule = - await this.stripe.subscriptionSchedules.retrieve(scheduleId); - - const currentPhase = schedule.phases.find( - phase => - phase.start_date * 1000 < Date.now() && - phase.end_date * 1000 > Date.now() - ); - - if ( - schedule.status !== 'active' || - schedule.phases.length > 2 || - !currentPhase - ) { - throw new Error('Unexpected subscription schedule status'); - } - - if (schedule.status !== 'active') { - throw new Error('unexpected subscription schedule status'); - } - - const nextPhase = schedule.phases.find( - phase => phase.start_date * 1000 > Date.now() - ); - - if (!currentPhase) { - throw new Error('Unexpected subscription schedule status'); - } - - const update: Stripe.SubscriptionScheduleUpdateParams.Phase = { - items: [ - { - price: currentPhase.items[0].price as string, - quantity: 1, - }, - ], - coupon: (currentPhase.coupon as string | null) ?? undefined, - start_date: currentPhase.start_date, - end_date: currentPhase.end_date, - }; - - if (nextPhase) { - // cancel a subscription with a schedule exiting will delete the upcoming phase, - // it's hard to recover the subscription to the original state if user wan't to resume before due. - // so we manually save the next phase's key information to metadata for later easy resuming. - update.metadata = { - next_coupon: (nextPhase.coupon as string | null) || null, // avoid empty string - next_price: nextPhase.items[0].price as string, - }; - } - - await this.stripe.subscriptionSchedules.update(schedule.id, { - phases: [update], - end_behavior: 'cancel', - }); - } - - private async resumeSubscriptionSchedule(scheduleId: string) { - const schedule = - await this.stripe.subscriptionSchedules.retrieve(scheduleId); - - const currentPhase = schedule.phases.find( - phase => - phase.start_date * 1000 < Date.now() && - phase.end_date * 1000 > Date.now() - ); - - if (schedule.status !== 'active' || !currentPhase) { - throw new Error('Unexpected subscription schedule status'); - } - - const update: Stripe.SubscriptionScheduleUpdateParams.Phase[] = [ - { - items: [ - { - price: currentPhase.items[0].price as string, - quantity: 1, - }, - ], - coupon: (currentPhase.coupon as string | null) ?? undefined, - start_date: currentPhase.start_date, - end_date: currentPhase.end_date, - metadata: { - next_coupon: null, - next_price: null, - }, - }, - ]; - - if (currentPhase.metadata && currentPhase.metadata.next_price) { - update.push({ - items: [ - { - price: currentPhase.metadata.next_price, - quantity: 1, - }, - ], - coupon: currentPhase.metadata.next_coupon || undefined, - }); - } - - await this.stripe.subscriptionSchedules.update(schedule.id, { - phases: update, - end_behavior: 'release', - }); - } - - /** - * we only schedule a new price when user change the recurring plan and there is now upcoming phases. - */ - private async scheduleNewPrice( - scheduleId: string, - priceId: string + private async getAvailableCoupon( + user: User, + couponType: CouponType ): Promise { - const schedule = - await this.stripe.subscriptionSchedules.retrieve(scheduleId); - - const currentPhase = schedule.phases.find( - phase => - phase.start_date * 1000 < Date.now() && - phase.end_date * 1000 > Date.now() - ); - - if (schedule.status !== 'active' || !currentPhase) { - throw new Error('Unexpected subscription schedule status'); + const earlyAccess = await this.user.isEarlyAccessUser(user.email); + if (earlyAccess) { + try { + const coupon = await this.stripe.coupons.retrieve(couponType); + return coupon.valid ? coupon.id : null; + } catch (e) { + this.logger.error('Failed to get early access coupon', e); + return null; + } } - // if current phase's plan matches target, just release the schedule - if (currentPhase.items[0].price === priceId) { - await this.stripe.subscriptionSchedules.release(scheduleId); - return null; - } else { - await this.stripe.subscriptionSchedules.update(schedule.id, { - phases: [ - { - items: [ - { - price: currentPhase.items[0].price as string, - }, - ], - start_date: schedule.phases[0].start_date, - end_date: schedule.phases[0].end_date, - }, - { - items: [ - { - price: priceId, - quantity: 1, - }, - ], - }, - ], - }); - - return scheduleId; - } + return null; } } diff --git a/packages/backend/server/src/modules/users/index.ts b/packages/backend/server/src/modules/users/index.ts index 40dc84ae46..308a1a9a13 100644 --- a/packages/backend/server/src/modules/users/index.ts +++ b/packages/backend/server/src/modules/users/index.ts @@ -7,6 +7,7 @@ import { UsersService } from './users'; @Module({ imports: [StorageModule], providers: [UserResolver, UsersService], + exports: [UsersService], }) export class UsersModule {} diff --git a/packages/backend/server/src/modules/users/users.ts b/packages/backend/server/src/modules/users/users.ts index 477adc1fd3..1d56c936b2 100644 --- a/packages/backend/server/src/modules/users/users.ts +++ b/packages/backend/server/src/modules/users/users.ts @@ -15,16 +15,21 @@ export class UsersService { async canEarlyAccess(email: string) { if (this.config.featureFlags.earlyAccessPreview && !isStaff(email)) { - return this.prisma.newFeaturesWaitingList - .findUnique({ - where: { email, type: NewFeaturesKind.EarlyAccess }, - }) - .catch(() => false); + return this.isEarlyAccessUser(email); } else { return true; } } + async isEarlyAccessUser(email: string) { + return this.prisma.newFeaturesWaitingList + .count({ + where: { email, type: NewFeaturesKind.EarlyAccess }, + }) + .then(count => count > 0) + .catch(() => false); + } + async getStorageQuotaById(id: string) { const features = await this.prisma.user .findUnique({ From 35dbbe561abcfc18dc23a0626f2a9a9761aea208 Mon Sep 17 00:00:00 2001 From: JimmFly Date: Fri, 27 Oct 2023 14:25:48 +0800 Subject: [PATCH 21/27] feat(core): adjust member tips (#4737) --- .../new-workspace-setting-detail/members.tsx | 50 +++++++++++++++++-- .../new-workspace-setting-detail/style.css.ts | 19 +++++++ packages/frontend/i18n/src/resources/en.json | 3 +- 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/members.tsx b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/members.tsx index 38c425c9c4..66eef647c6 100644 --- a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/members.tsx +++ b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/members.tsx @@ -10,9 +10,9 @@ import { pushNotificationAtom } from '@affine/component/notification-center'; import { SettingRow } from '@affine/component/setting-components'; import type { AffineOfficialWorkspace } from '@affine/env/workspace'; import { WorkspaceFlavour } from '@affine/env/workspace'; -import { Permission } from '@affine/graphql'; +import { Permission, SubscriptionPlan } from '@affine/graphql'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { MoreVerticalIcon } from '@blocksuite/icons'; +import { ArrowRightBigIcon, MoreVerticalIcon } from '@blocksuite/icons'; import { Avatar } from '@toeverything/components/avatar'; import { Button, IconButton } from '@toeverything/components/button'; import { Loading } from '@toeverything/components/loading'; @@ -31,16 +31,24 @@ import { } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; +import { openSettingModalAtom } from '../../../atoms'; import type { CheckedUser } from '../../../hooks/affine/use-current-user'; import { useCurrentUser } from '../../../hooks/affine/use-current-user'; import { useInviteMember } from '../../../hooks/affine/use-invite-member'; import { useMemberCount } from '../../../hooks/affine/use-member-count'; import { type Member, useMembers } from '../../../hooks/affine/use-members'; import { useRevokeMemberPermission } from '../../../hooks/affine/use-revoke-member-permission'; +import { useUserSubscription } from '../../../hooks/use-subscription'; import { AnyErrorBoundary } from '../any-error-boundary'; import * as style from './style.css'; import type { WorkspaceSettingDetailProps } from './types'; +enum MemberLimitCount { + Free = '3', + Pro = '10', + Other = '?', +} + const COUNT_PER_PAGE = 8; export interface MembersPanelProps extends WorkspaceSettingDetailProps { workspace: AffineOfficialWorkspace; @@ -130,11 +138,47 @@ export const CloudWorkspaceMembersPanel = ({ [pushNotification, revokeMemberPermission, t] ); + const setSettingModalAtom = useSetAtom(openSettingModalAtom); + const handleUpgrade = useCallback(() => { + setSettingModalAtom({ + open: true, + activeTab: 'plans', + workspaceId: null, + }); + }, [setSettingModalAtom]); + + const [subscription] = useUserSubscription(); + const plan = subscription?.plan ?? SubscriptionPlan.Free; + const memberLimit = useMemo(() => { + if (plan === SubscriptionPlan.Free) { + return MemberLimitCount.Free; + } + if (plan === SubscriptionPlan.Pro) { + return MemberLimitCount.Pro; + } + return MemberLimitCount.Other; + }, [plan]); + const desc = useMemo(() => { + return ( + + {t['com.affine.payment.member.description']({ + planName: plan, + memberLimit, + })} + , +
+ go upgrade + +
+
+ ); + }, [handleUpgrade, memberLimit, plan, t]); + return ( <> {isOwner ? ( diff --git a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/style.css.ts b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/style.css.ts index 988c1d32e8..a716ec60c5 100644 --- a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/style.css.ts +++ b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/style.css.ts @@ -182,3 +182,22 @@ export const workspaceLabel = style({ lineHeight: '20px', whiteSpace: 'nowrap', }); + +export const goUpgrade = style({ + fontSize: 'var(--affine-font-xs)', + color: 'var(--affine-text-emphasis-color)', + cursor: 'pointer', + marginLeft: '4px', + display: 'inline', +}); + +export const goUpgradeWrapper = style({ + display: 'inline-flex', + alignItems: 'center', +}); + +export const arrowRight = style({ + fontSize: '16px', + color: 'var(--affine-text-emphasis-color)', + cursor: 'pointer', +}); diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index d8b0c50c4d..52fad8b4fb 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -711,5 +711,6 @@ "com.affine.payment.billing-setting.resume-subscription": "Resume", "com.affine.payment.billing-setting.no-invoice": "There are no invoices to display.", "com.affine.payment.billing-setting.paid": "Paid", - "com.affine.payment.billing-setting.view-invoice": "View Invoice" + "com.affine.payment.billing-setting.view-invoice": "View Invoice", + "com.affine.payment.member.description": "Manage members here. {{planName}} Users can invite up to {{memberLimit}}" } From af2433426474923c58e1f95828d303f20717086f Mon Sep 17 00:00:00 2001 From: JimmFly Date: Fri, 27 Oct 2023 15:49:32 +0800 Subject: [PATCH 22/27] feat(core): add upgrade success page (#4738) --- .../server/src/modules/payment/resolver.ts | 3 +- .../server/src/modules/payment/webhook.ts | 18 +-- .../core/src/pages/upgrade-success.css.ts | 93 ++++++++++++++++ .../core/src/pages/upgrade-success.tsx | 105 ++++++++++++++++++ packages/frontend/core/src/router.ts | 4 + packages/frontend/i18n/src/resources/en.json | 9 ++ 6 files changed, 213 insertions(+), 19 deletions(-) create mode 100644 packages/frontend/core/src/pages/upgrade-success.css.ts create mode 100644 packages/frontend/core/src/pages/upgrade-success.tsx diff --git a/packages/backend/server/src/modules/payment/resolver.ts b/packages/backend/server/src/modules/payment/resolver.ts index a7ef4b7cc4..653cd06466 100644 --- a/packages/backend/server/src/modules/payment/resolver.ts +++ b/packages/backend/server/src/modules/payment/resolver.ts @@ -194,8 +194,7 @@ export class SubscriptionResolver { const session = await this.service.createCheckoutSession({ user, recurring, - // TODO: replace with frontend url - redirectUrl: `${this.config.baseUrl}/api/stripe/success`, + redirectUrl: `${this.config.baseUrl}/upgrade-success`, }); if (!session.url) { diff --git a/packages/backend/server/src/modules/payment/webhook.ts b/packages/backend/server/src/modules/payment/webhook.ts index 1fda0b6411..13f692132d 100644 --- a/packages/backend/server/src/modules/payment/webhook.ts +++ b/packages/backend/server/src/modules/payment/webhook.ts @@ -1,20 +1,16 @@ import type { RawBodyRequest } from '@nestjs/common'; import { Controller, - Get, Logger, NotAcceptableException, Post, Req, } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; -import type { User } from '@prisma/client'; import type { Request } from 'express'; import Stripe from 'stripe'; import { Config } from '../../config'; -import { PrismaService } from '../../prisma'; -import { Auth, CurrentUser } from '../auth'; @Controller('/api/stripe') export class StripeWebhook { @@ -24,23 +20,11 @@ export class StripeWebhook { constructor( config: Config, private readonly stripe: Stripe, - private readonly event: EventEmitter2, - private readonly db: PrismaService + private readonly event: EventEmitter2 ) { this.config = config.payment; } - // just for test - @Auth() - @Get('/success') - async handleSuccess(@CurrentUser() user: User) { - return this.db.userSubscription.findUnique({ - where: { - userId: user.id, - }, - }); - } - @Post('/webhook') async handleWebhook(@Req() req: RawBodyRequest) { // Check if webhook signing is configured. diff --git a/packages/frontend/core/src/pages/upgrade-success.css.ts b/packages/frontend/core/src/pages/upgrade-success.css.ts new file mode 100644 index 0000000000..4183c5ae7f --- /dev/null +++ b/packages/frontend/core/src/pages/upgrade-success.css.ts @@ -0,0 +1,93 @@ +import { style } from '@vanilla-extract/css'; + +export const root = style({ + height: '100vh', + width: '100vw', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + fontSize: 'var(--affine-font-base)', + position: 'relative', +}); + +export const affineLogo = style({ + color: 'inherit', +}); + +export const topNav = style({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '16px 120px', +}); + +export const topNavLinks = style({ + display: 'flex', + columnGap: 4, +}); + +export const topNavLink = style({ + color: 'var(--affine-text-primary-color)', + fontSize: 'var(--affine-font-sm)', + fontWeight: 500, + textDecoration: 'none', + padding: '4px 18px', +}); + +export const tryAgainLink = style({ + color: 'var(--affine-link-color)', + fontWeight: 500, + textDecoration: 'none', + fontSize: 'var(--affine-font-sm)', +}); + +export const centerContent = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + marginTop: 40, +}); + +export const prompt = style({ + marginTop: 20, + marginBottom: 12, +}); + +export const body = style({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + width: '100%', + flexWrap: 'wrap', + gap: '48px', + padding: '0 20px', +}); + +export const leftContainer = style({ + display: 'flex', + flexDirection: 'column', + width: '548px', + gap: '28px', +}); +export const leftContentTitle = style({ + fontSize: 'var(--affine-font-title)', + fontWeight: 700, + minHeight: '44px', +}); +export const leftContentText = style({ + fontSize: 'var(--affine-font-base)', + fontWeight: 400, + lineHeight: '1.6', +}); + +export const mail = style({ + color: 'var(--affine-link-color)', + textDecoration: 'none', + ':visited': { + color: 'var(--affine-link-color)', + }, +}); diff --git a/packages/frontend/core/src/pages/upgrade-success.tsx b/packages/frontend/core/src/pages/upgrade-success.tsx new file mode 100644 index 0000000000..a8af870ead --- /dev/null +++ b/packages/frontend/core/src/pages/upgrade-success.tsx @@ -0,0 +1,105 @@ +import { Empty } from '@affine/component'; +import { Trans } from '@affine/i18n'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { Logo1Icon } from '@blocksuite/icons'; +import { Button } from '@toeverything/components/button'; +import { useCallback } from 'react'; + +import { useNavigateHelper } from '../hooks/use-navigate-helper'; +import * as styles from './upgrade-success.css'; + +export const UpgradeSuccess = () => { + const t = useAFFiNEI18N(); + + const openDownloadLink = useCallback(() => { + const url = `https://affine.pro/download`; + open(url, '_blank'); + }, []); + + const { jumpToIndex } = useNavigateHelper(); + const openAffine = useCallback(() => { + jumpToIndex(); + }, [jumpToIndex]); + + return ( +
+ +
+
+
+ {t['com.affine.payment.upgrade-success-page.title']()} +
+
+ {t['com.affine.payment.upgrade-success-page.text']()} +
+ + ), + }} + /> +
+
+
+ +
+
+ +
+
+ ); +}; + +export const Component = () => { + return ; +}; diff --git a/packages/frontend/core/src/router.ts b/packages/frontend/core/src/router.ts index b067d9d7e8..944bbded39 100644 --- a/packages/frontend/core/src/router.ts +++ b/packages/frontend/core/src/router.ts @@ -52,6 +52,10 @@ export const routes = [ path: '/open-app/:action', lazy: () => import('./pages/open-app'), }, + { + path: '/upgrade-success', + lazy: () => import('./pages/upgrade-success'), + }, { path: '/desktop-signin', lazy: () => import('./pages/desktop-signin'), diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 52fad8b4fb..813d159582 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -712,5 +712,14 @@ "com.affine.payment.billing-setting.no-invoice": "There are no invoices to display.", "com.affine.payment.billing-setting.paid": "Paid", "com.affine.payment.billing-setting.view-invoice": "View Invoice", + "com.affine.payment.upgrade-success-page.title": "Upgrade Successful!", + "com.affine.payment.upgrade-success-page.text": "Congratulations! Your AFFiNE account has been successfully upgraded to a Pro account.", + "com.affine.payment.upgrade-success-page.support": "If you have any questions, please contact our <1> customer support.", + "com.affine.other-page.nav.official-website": "Official Website", + "com.affine.other-page.nav.affine-community": "AFFiNE Community", + "com.affine.other-page.nav.blog": "Blog", + "com.affine.other-page.nav.contact-us": "Contact us", + "com.affine.other-page.nav.download-app": "Download App", + "com.affine.other-page.nav.open-affine": "Open AFFiNE", "com.affine.payment.member.description": "Manage members here. {{planName}} Users can invite up to {{memberLimit}}" } From 87571a0879fc4f74d829a3058e3d4ad555960e2f Mon Sep 17 00:00:00 2001 From: Cats Juice Date: Fri, 27 Oct 2023 15:59:41 +0800 Subject: [PATCH 23/27] chore(core): replace setting-modal sidebar icons (#4742) --- .../setting-modal/general-setting/icons.tsx | 41 +++++++++++++++++++ .../setting-modal/general-setting/index.tsx | 7 ++-- 2 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/icons.tsx diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/icons.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/icons.tsx new file mode 100644 index 0000000000..8933141a30 --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/icons.tsx @@ -0,0 +1,41 @@ +import type { SVGProps } from 'react'; + +export const UpgradeIcon = ({ width, height }: SVGProps) => { + return ( + + + + ); +}; + +export const PaymentIcon = ({ width, height }: SVGProps) => { + return ( + + + + ); +}; 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 241170a1d0..956d99bd5b 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 @@ -11,6 +11,7 @@ import { useCurrentLoginStatus } from '../../../../hooks/affine/use-current-logi import { AboutAffine } from './about'; import { AppearanceSettings } from './appearance'; import { BillingSettings } from './billing'; +import { PaymentIcon, UpgradeIcon } from './icons'; import { AFFiNECloudPlans } from './plans'; import { Plugins } from './plugins'; import { Shortcuts } from './shortcuts'; @@ -52,8 +53,7 @@ export const useGeneralSettingList = (): GeneralSettingList => { { key: 'plans', title: t['com.affine.payment.title'](), - // TODO: icon - icon: KeyboardIcon, + icon: UpgradeIcon, testId: 'plans-panel-trigger', }, @@ -75,8 +75,7 @@ export const useGeneralSettingList = (): GeneralSettingList => { settings.splice(3, 0, { key: 'billing', title: t['com.affine.payment.billing-setting.title'](), - // TODO: icon - icon: KeyboardIcon, + icon: PaymentIcon, testId: 'billing-panel-trigger', }); } From abbd8235aa3c3ff8054c7408f0f4a39de9b593ae Mon Sep 17 00:00:00 2001 From: Joooye_34 Date: Fri, 27 Oct 2023 16:59:13 +0800 Subject: [PATCH 24/27] chore(core): enable payment in canary (#4745) --- packages/frontend/core/.webpack/runtime-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend/core/.webpack/runtime-config.ts b/packages/frontend/core/.webpack/runtime-config.ts index a93c354394..1e75ee07b8 100644 --- a/packages/frontend/core/.webpack/runtime-config.ts +++ b/packages/frontend/core/.webpack/runtime-config.ts @@ -66,7 +66,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig { enableCloud: true, enableCaptcha: true, enableEnhanceShareMode: false, - enablePayment: false, + enablePayment: true, serverUrlPrefix: 'https://affine.fail', editorFlags, appVersion: packageJson.version, From 8c194ab8b0253b215570f17a7dd4b90c58846326 Mon Sep 17 00:00:00 2001 From: Cats Juice Date: Sat, 28 Oct 2023 14:12:25 +0800 Subject: [PATCH 25/27] feat(core): confirm before cancel in billing page (#4749) --- .../general-setting/billing/index.tsx | 49 ++++++++++--------- 1 file changed, 27 insertions(+), 22 deletions(-) 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 a1eab2ab5d..ec4ef72362 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 @@ -21,7 +21,7 @@ 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, useMemo } from 'react'; +import { Suspense, useCallback, useMemo, useState } from 'react'; import { openSettingModalAtom } from '../../../../../atoms'; import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status'; @@ -29,6 +29,7 @@ import { type SubscriptionMutator, useUserSubscription, } from '../../../../../hooks/use-subscription'; +import { DowngradeModal } from '../plans/modals'; import * as styles from './style.css'; enum DescriptionI18NKey { @@ -83,6 +84,19 @@ export const BillingSettings = () => { const SubscriptionSettings = () => { const [subscription, mutateSubscription] = useUserSubscription(); + const { isMutating, trigger } = useMutation({ + mutation: cancelSubscriptionMutation, + }); + const [openCancelModal, setOpenCancelModal] = useState(false); + + const cancel = useCallback(() => { + trigger(null, { + onSuccess: data => { + mutateSubscription(data.cancelSubscription); + }, + }); + }, [trigger, mutateSubscription]); + const { data: pricesQueryResult } = useQuery({ query: pricesQuery, }); @@ -189,6 +203,8 @@ const SubscriptionSettings = () => {
) : ( (isMutating ? null : setOpenCancelModal(true))} className="dangerous-setting" name={t[ 'com.affine.payment.billing-setting.cancel-subscription' @@ -199,9 +215,14 @@ const SubscriptionSettings = () => { cancelDate: new Date(subscription.end).toLocaleDateString(), })} > - + )} + (isMutating ? null : cancel())} + onOpenChange={setOpenCancelModal} + /> )}
@@ -287,29 +308,13 @@ const ResumeSubscription = ({ ); }; -const CancelSubscription = ({ - onSubscriptionUpdate, -}: { - onSubscriptionUpdate: SubscriptionMutator; -}) => { - const { isMutating, trigger } = useMutation({ - mutation: cancelSubscriptionMutation, - }); - - const cancel = useCallback(() => { - trigger(null, { - onSuccess: data => { - onSubscriptionUpdate(data.cancelSubscription); - }, - }); - }, [trigger, onSubscriptionUpdate]); - +const CancelSubscription = ({ loading }: { loading?: boolean }) => { return ( } - disabled={isMutating} - loading={isMutating} - onClick={cancel /* TODO: popup confirmation modal instead */} + disabled={loading} + loading={loading} /> ); }; From 3798293d3e4d9f66f49b0a223b01be41252f401d Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Sun, 29 Oct 2023 11:33:21 -0500 Subject: [PATCH 26/27] fix: error handle in payment resolver (#4754) --- .../server/src/modules/payment/resolver.ts | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/packages/backend/server/src/modules/payment/resolver.ts b/packages/backend/server/src/modules/payment/resolver.ts index 653cd06466..22d24c43a5 100644 --- a/packages/backend/server/src/modules/payment/resolver.ts +++ b/packages/backend/server/src/modules/payment/resolver.ts @@ -1,8 +1,4 @@ -import { - BadGatewayException, - ForbiddenException, - InternalServerErrorException, -} from '@nestjs/common'; +import { HttpStatus } from '@nestjs/common'; import { Args, Field, @@ -16,6 +12,7 @@ import { Resolver, } from '@nestjs/graphql'; import type { User, UserInvoice, UserSubscription } from '@prisma/client'; +import { GraphQLError } from 'graphql'; import { groupBy } from 'lodash-es'; import { Config } from '../../config'; @@ -168,9 +165,12 @@ export class SubscriptionResolver { ); if (!yearly || !monthly) { - throw new BadGatewayException( - 'The prices are not configured correctly' - ); + throw new GraphQLError('The prices are not configured correctly', { + extensions: { + status: HttpStatus[HttpStatus.BAD_GATEWAY], + code: HttpStatus.BAD_GATEWAY, + }, + }); } return { @@ -198,9 +198,12 @@ export class SubscriptionResolver { }); if (!session.url) { - throw new InternalServerErrorException( - 'Failed to create checkout session' - ); + throw new GraphQLError('Failed to create checkout session', { + extensions: { + status: HttpStatus[HttpStatus.BAD_GATEWAY], + code: HttpStatus.BAD_GATEWAY, + }, + }); } return session.url; @@ -240,7 +243,15 @@ export class UserSubscriptionResolver { @ResolveField(() => UserSubscriptionType, { nullable: true }) async subscription(@CurrentUser() me: User, @Parent() user: User) { if (me.id !== user.id) { - throw new ForbiddenException(); + throw new GraphQLError( + 'You are not allowed to access this subscription', + { + extensions: { + status: HttpStatus[HttpStatus.FORBIDDEN], + code: HttpStatus.FORBIDDEN, + }, + } + ); } return this.db.userSubscription.findUnique({ @@ -259,7 +270,12 @@ export class UserSubscriptionResolver { @Args('skip', { type: () => Int, nullable: true }) skip?: number ) { if (me.id !== user.id) { - throw new ForbiddenException(); + throw new GraphQLError('You are not allowed to access this invoices', { + extensions: { + status: HttpStatus[HttpStatus.FORBIDDEN], + code: HttpStatus.FORBIDDEN, + }, + }); } return this.db.userInvoice.findMany({ From de9e7f97a4649be062a7c5d7245b6b128370a5c0 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Mon, 30 Oct 2023 00:54:09 -0500 Subject: [PATCH 27/27] feat: add idempotent request support for payment apis (#4753) --- .../server/src/modules/payment/resolver.ts | 27 +++-- .../server/src/modules/payment/schedule.ts | 108 +++++++++++------- .../server/src/modules/payment/service.ts | 100 +++++++++------- packages/backend/server/src/schema.gql | 8 +- .../general-setting/billing/index.tsx | 37 ++++-- .../general-setting/plans/plan-card.tsx | 52 ++++++--- .../src/graphql/cancel-subscription.gql | 4 +- .../src/graphql/create-checkout-link.gql | 7 +- .../frontend/graphql/src/graphql/index.ts | 19 +-- .../src/graphql/resume-subscription.gql | 4 +- .../graphql/update-subscription-billing.gql | 10 +- packages/frontend/graphql/src/schema.ts | 6 +- 12 files changed, 244 insertions(+), 138 deletions(-) diff --git a/packages/backend/server/src/modules/payment/resolver.ts b/packages/backend/server/src/modules/payment/resolver.ts index 22d24c43a5..f9950bfd9d 100644 --- a/packages/backend/server/src/modules/payment/resolver.ts +++ b/packages/backend/server/src/modules/payment/resolver.ts @@ -189,12 +189,14 @@ export class SubscriptionResolver { async checkout( @CurrentUser() user: User, @Args({ name: 'recurring', type: () => SubscriptionRecurring }) - recurring: SubscriptionRecurring + recurring: SubscriptionRecurring, + @Args('idempotencyKey') idempotencyKey: string ) { const session = await this.service.createCheckoutSession({ user, recurring, redirectUrl: `${this.config.baseUrl}/upgrade-success`, + idempotencyKey, }); if (!session.url) { @@ -217,22 +219,33 @@ export class SubscriptionResolver { } @Mutation(() => UserSubscriptionType) - async cancelSubscription(@CurrentUser() user: User) { - return this.service.cancelSubscription(user.id); + async cancelSubscription( + @CurrentUser() user: User, + @Args('idempotencyKey') idempotencyKey: string + ) { + return this.service.cancelSubscription(idempotencyKey, user.id); } @Mutation(() => UserSubscriptionType) - async resumeSubscription(@CurrentUser() user: User) { - return this.service.resumeCanceledSubscription(user.id); + async resumeSubscription( + @CurrentUser() user: User, + @Args('idempotencyKey') idempotencyKey: string + ) { + return this.service.resumeCanceledSubscription(idempotencyKey, user.id); } @Mutation(() => UserSubscriptionType) async updateSubscriptionRecurring( @CurrentUser() user: User, @Args({ name: 'recurring', type: () => SubscriptionRecurring }) - recurring: SubscriptionRecurring + recurring: SubscriptionRecurring, + @Args('idempotencyKey') idempotencyKey: string ) { - return this.service.updateSubscriptionRecurring(user.id, recurring); + return this.service.updateSubscriptionRecurring( + idempotencyKey, + user.id, + recurring + ); } } diff --git a/packages/backend/server/src/modules/payment/schedule.ts b/packages/backend/server/src/modules/payment/schedule.ts index 423bdf6916..e27838e202 100644 --- a/packages/backend/server/src/modules/payment/schedule.ts +++ b/packages/backend/server/src/modules/payment/schedule.ts @@ -1,9 +1,10 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import Stripe from 'stripe'; @Injectable() export class ScheduleManager { private _schedule: Stripe.SubscriptionSchedule | null = null; + private readonly logger = new Logger(ScheduleManager.name); constructor(private readonly stripe: Stripe) {} @@ -50,7 +51,10 @@ export class ScheduleManager { if (typeof schedule === 'string') { const s = await this.stripe.subscriptionSchedules .retrieve(schedule) - .catch(() => undefined); + .catch(e => { + this.logger.error('Failed to retrieve subscription schedule', e); + return undefined; + }); return ScheduleManager.create(this.stripe, s); } else { @@ -58,7 +62,10 @@ export class ScheduleManager { } } - async fromSubscription(subscription: string | Stripe.Subscription) { + async fromSubscription( + idempotencyKey: string, + subscription: string | Stripe.Subscription + ) { if (typeof subscription === 'string') { subscription = await this.stripe.subscriptions.retrieve(subscription, { expand: ['schedule'], @@ -68,9 +75,10 @@ export class ScheduleManager { if (subscription.schedule) { return await this.fromSchedule(subscription.schedule); } else { - const schedule = await this.stripe.subscriptionSchedules.create({ - from_subscription: subscription.id, - }); + const schedule = await this.stripe.subscriptionSchedules.create( + { from_subscription: subscription.id }, + { idempotencyKey } + ); return await this.fromSchedule(schedule); } @@ -80,7 +88,7 @@ export class ScheduleManager { * Cancel a subscription by marking schedule's end behavior to `cancel`. * At the same time, the coming phase's price and coupon will be saved to metadata for later resuming to correction subscription. */ - async cancel() { + async cancel(idempotencyKey: string) { if (!this._schedule) { throw new Error('No schedule'); } @@ -111,13 +119,17 @@ export class ScheduleManager { }; } - await this.stripe.subscriptionSchedules.update(this._schedule.id, { - phases: [phases], - end_behavior: 'cancel', - }); + await this.stripe.subscriptionSchedules.update( + this._schedule.id, + { + phases: [phases], + end_behavior: 'cancel', + }, + { idempotencyKey } + ); } - async resume() { + async resume(idempotencyKey: string) { if (!this._schedule) { throw new Error('No schedule'); } @@ -156,21 +168,27 @@ export class ScheduleManager { }); } - await this.stripe.subscriptionSchedules.update(this._schedule.id, { - phases: phases, - end_behavior: 'release', - }); + await this.stripe.subscriptionSchedules.update( + this._schedule.id, + { + phases: phases, + end_behavior: 'release', + }, + { idempotencyKey } + ); } - async release() { + async release(idempotencyKey: string) { if (!this._schedule) { throw new Error('No schedule'); } - await this.stripe.subscriptionSchedules.release(this._schedule.id); + await this.stripe.subscriptionSchedules.release(this._schedule.id, { + idempotencyKey, + }); } - async update(price: string, coupon?: string) { + async update(idempotencyKey: string, price: string, coupon?: string) { if (!this._schedule) { throw new Error('No schedule'); } @@ -184,31 +202,37 @@ export class ScheduleManager { this.currentPhase.items[0].price === price && (!coupon || this.currentPhase.coupon === coupon) ) { - await this.stripe.subscriptionSchedules.release(this._schedule.id); + await this.stripe.subscriptionSchedules.release(this._schedule.id, { + idempotencyKey, + }); this._schedule = null; } else { - await this.stripe.subscriptionSchedules.update(this._schedule.id, { - phases: [ - { - items: [ - { - price: this.currentPhase.items[0].price as string, - }, - ], - start_date: this.currentPhase.start_date, - end_date: this.currentPhase.end_date, - }, - { - items: [ - { - price: price, - quantity: 1, - }, - ], - coupon, - }, - ], - }); + await this.stripe.subscriptionSchedules.update( + this._schedule.id, + { + phases: [ + { + items: [ + { + price: this.currentPhase.items[0].price as string, + }, + ], + start_date: this.currentPhase.start_date, + end_date: this.currentPhase.end_date, + }, + { + items: [ + { + price: price, + quantity: 1, + }, + ], + coupon, + }, + ], + }, + { idempotencyKey } + ); } } } diff --git a/packages/backend/server/src/modules/payment/service.ts b/packages/backend/server/src/modules/payment/service.ts index 91d98d2788..71b09f99ce 100644 --- a/packages/backend/server/src/modules/payment/service.ts +++ b/packages/backend/server/src/modules/payment/service.ts @@ -103,12 +103,14 @@ export class SubscriptionService { user, recurring, redirectUrl, + idempotencyKey, plan = SubscriptionPlan.Pro, }: { user: User; plan?: SubscriptionPlan; recurring: SubscriptionRecurring; redirectUrl: string; + idempotencyKey: string; }) { const currentSubscription = await this.db.userSubscription.findUnique({ where: { @@ -121,37 +123,43 @@ export class SubscriptionService { } const price = await this.getPrice(plan, recurring); - const customer = await this.getOrCreateCustomer(user); + const customer = await this.getOrCreateCustomer(idempotencyKey, user); const coupon = await this.getAvailableCoupon(user, CouponType.EarlyAccess); - return await this.stripe.checkout.sessions.create({ - line_items: [ - { - price, - quantity: 1, + return await this.stripe.checkout.sessions.create( + { + line_items: [ + { + price, + quantity: 1, + }, + ], + tax_id_collection: { + enabled: true, + }, + ...(coupon + ? { + discounts: [{ coupon }], + } + : { + allow_promotion_codes: true, + }), + mode: 'subscription', + success_url: redirectUrl, + customer: customer.stripeCustomerId, + customer_update: { + address: 'auto', + name: 'auto', }, - ], - tax_id_collection: { - enabled: true, }, - ...(coupon - ? { - discounts: [{ coupon }], - } - : { - allow_promotion_codes: true, - }), - mode: 'subscription', - success_url: redirectUrl, - customer: customer.stripeCustomerId, - customer_update: { - address: 'auto', - name: 'auto', - }, - }); + { idempotencyKey } + ); } - async cancelSubscription(userId: string): Promise { + async cancelSubscription( + idempotencyKey: string, + userId: string + ): Promise { const user = await this.db.user.findUnique({ where: { id: userId, @@ -174,7 +182,7 @@ export class SubscriptionService { const manager = await this.scheduleManager.fromSchedule( user.subscription.stripeScheduleId ); - await manager.cancel(); + await manager.cancel(idempotencyKey); return this.saveSubscription( user, await this.stripe.subscriptions.retrieve( @@ -187,15 +195,17 @@ export class SubscriptionService { // see https://stripe.com/docs/billing/subscriptions/cancel const subscription = await this.stripe.subscriptions.update( user.subscription.stripeSubscriptionId, - { - cancel_at_period_end: true, - } + { cancel_at_period_end: true }, + { idempotencyKey } ); return await this.saveSubscription(user, subscription); } } - async resumeCanceledSubscription(userId: string): Promise { + async resumeCanceledSubscription( + idempotencyKey: string, + userId: string + ): Promise { const user = await this.db.user.findUnique({ where: { id: userId, @@ -221,7 +231,7 @@ export class SubscriptionService { const manager = await this.scheduleManager.fromSchedule( user.subscription.stripeScheduleId ); - await manager.resume(); + await manager.resume(idempotencyKey); return this.saveSubscription( user, await this.stripe.subscriptions.retrieve( @@ -232,9 +242,8 @@ export class SubscriptionService { } else { const subscription = await this.stripe.subscriptions.update( user.subscription.stripeSubscriptionId, - { - cancel_at_period_end: false, - } + { cancel_at_period_end: false }, + { idempotencyKey } ); return await this.saveSubscription(user, subscription); @@ -242,6 +251,7 @@ export class SubscriptionService { } async updateSubscriptionRecurring( + idempotencyKey: string, userId: string, recurring: SubscriptionRecurring ): Promise { @@ -272,10 +282,12 @@ export class SubscriptionService { ); const manager = await this.scheduleManager.fromSubscription( + idempotencyKey, user.subscription.stripeSubscriptionId ); await manager.update( + idempotencyKey, price, // if user is early access user, use early access coupon manager.currentPhase?.coupon === CouponType.EarlyAccess || @@ -355,10 +367,16 @@ export class SubscriptionService { // deal with early access user if (stripeInvoice.discount?.coupon.id === CouponType.EarlyAccess) { + const idempotencyKey = stripeInvoice.id + '_earlyaccess'; const manager = await this.scheduleManager.fromSubscription( + idempotencyKey, line.subscription as string ); - await manager.update(line.price.id, CouponType.EarlyAccessRenew); + await manager.update( + idempotencyKey, + line.price.id, + CouponType.EarlyAccessRenew + ); } } @@ -530,7 +548,10 @@ export class SubscriptionService { } } - private async getOrCreateCustomer(user: User): Promise { + private async getOrCreateCustomer( + idempotencyKey: string, + user: User + ): Promise { const customer = await this.db.userStripeCustomer.findUnique({ where: { userId: user.id, @@ -550,9 +571,10 @@ export class SubscriptionService { if (stripeCustomersList.data.length) { stripeCustomer = stripeCustomersList.data[0]; } else { - stripeCustomer = await this.stripe.customers.create({ - email: user.email, - }); + stripeCustomer = await this.stripe.customers.create( + { email: user.email }, + { idempotencyKey } + ); } return await this.db.userStripeCustomer.create({ diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 5b62bdaa07..dc68b19d1a 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -278,13 +278,13 @@ type Mutation { addToNewFeaturesWaitingList(type: NewFeaturesKind!, email: String!): AddToNewFeaturesWaitingList! """Create a subscription checkout link of stripe""" - checkout(recurring: SubscriptionRecurring!): String! + checkout(recurring: SubscriptionRecurring!, idempotencyKey: String!): String! """Create a stripe customer portal to manage payment methods""" createCustomerPortal: String! - cancelSubscription: UserSubscription! - resumeSubscription: UserSubscription! - updateSubscriptionRecurring(recurring: SubscriptionRecurring!): UserSubscription! + cancelSubscription(idempotencyKey: String!): UserSubscription! + resumeSubscription(idempotencyKey: String!): UserSubscription! + updateSubscriptionRecurring(recurring: SubscriptionRecurring!, idempotencyKey: String!): UserSubscription! } """The `Upload` scalar type represents a file upload.""" 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 ec4ef72362..bb417f3295 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 @@ -21,6 +21,7 @@ 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 { nanoid } from 'nanoid'; import { Suspense, useCallback, useMemo, useState } from 'react'; import { openSettingModalAtom } from '../../../../../atoms'; @@ -89,13 +90,19 @@ const SubscriptionSettings = () => { }); const [openCancelModal, setOpenCancelModal] = useState(false); + // allow replay request on network error until component unmount + const idempotencyKey = useMemo(() => nanoid(), []); + const cancel = useCallback(() => { - trigger(null, { - onSuccess: data => { - mutateSubscription(data.cancelSubscription); - }, - }); - }, [trigger, mutateSubscription]); + trigger( + { idempotencyKey }, + { + onSuccess: data => { + mutateSubscription(data.cancelSubscription); + }, + } + ); + }, [trigger, idempotencyKey, mutateSubscription]); const { data: pricesQueryResult } = useQuery({ query: pricesQuery, @@ -288,13 +295,19 @@ const ResumeSubscription = ({ mutation: resumeSubscriptionMutation, }); + // allow replay request on network error until component unmount + const idempotencyKey = useMemo(() => nanoid(), []); + const resume = useCallback(() => { - trigger(null, { - onSuccess: data => { - onSubscriptionUpdate(data.resumeSubscription); - }, - }); - }, [trigger, onSubscriptionUpdate]); + trigger( + { idempotencyKey }, + { + onSuccess: data => { + onSubscriptionUpdate(data.resumeSubscription); + }, + } + ); + }, [trigger, idempotencyKey, onSubscriptionUpdate]); return (