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/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/package.json b/packages/backend/server/package.json index 2a4d5b2c4a..588d8761b1 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -27,6 +27,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", @@ -74,6 +75,7 @@ "rxjs": "^7.8.1", "semver": "^7.5.4", "socket.io": "^4.7.2", + "stripe": "^14.1.0", "ws": "^8.14.2", "yjs": "^13.6.8" }, diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 72a28abad5..6c94a7d64f 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") } @@ -165,6 +168,70 @@ 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 + // stripe hosted invoice link + link String? @db.Text + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("user_invoices") +} + model DataMigration { id String @id @default(uuid()) @db.VarChar(36) name String @db.VarChar 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..d49aad9709 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-10-16', + }, + }, } satisfies AFFiNEConfig; applyEnvToConfig(defaultConfig); diff --git a/packages/backend/server/src/index.ts b/packages/backend/server/src/index.ts index d4be178348..a124df389a 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..30393504b5 --- /dev/null +++ b/packages/backend/server/src/modules/payment/index.ts @@ -0,0 +1,21 @@ +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, + 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..f9950bfd9d --- /dev/null +++ b/packages/backend/server/src/modules/payment/resolver.ts @@ -0,0 +1,305 @@ +import { HttpStatus } 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 { GraphQLError } from 'graphql'; +import { groupBy } from 'lodash-es'; + +import { Config } from '../../config'; +import { PrismaService } from '../../prisma'; +import { Auth, CurrentUser, Public } from '../auth'; +import { UserType } from '../users'; +import { + decodeLookupKey, + 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(() => String, { nullable: true }) + link?: 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 group = groupBy( + prices.data.filter(price => !!price.lookup_key), + price => { + // @ts-expect-error empty lookup key is filtered out + const [plan] = decodeLookupKey(price.lookup_key); + return plan; + } + ); + + return Object.entries(group).map(([plan, prices]) => { + const yearly = prices.find( + price => + decodeLookupKey( + // @ts-expect-error empty lookup key is filtered out + price.lookup_key + )[1] === SubscriptionRecurring.Yearly + ); + const monthly = prices.find( + price => + decodeLookupKey( + // @ts-expect-error empty lookup key is filtered out + price.lookup_key + )[1] === SubscriptionRecurring.Monthly + ); + + if (!yearly || !monthly) { + throw new GraphQLError('The prices are not configured correctly', { + extensions: { + status: HttpStatus[HttpStatus.BAD_GATEWAY], + code: HttpStatus.BAD_GATEWAY, + }, + }); + } + + return { + type: 'fixed', + plan: plan as SubscriptionPlan, + 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, + @Args('idempotencyKey') idempotencyKey: string + ) { + const session = await this.service.createCheckoutSession({ + user, + recurring, + redirectUrl: `${this.config.baseUrl}/upgrade-success`, + idempotencyKey, + }); + + if (!session.url) { + throw new GraphQLError('Failed to create checkout session', { + extensions: { + status: HttpStatus[HttpStatus.BAD_GATEWAY], + code: HttpStatus.BAD_GATEWAY, + }, + }); + } + + 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, + @Args('idempotencyKey') idempotencyKey: string + ) { + return this.service.cancelSubscription(idempotencyKey, user.id); + } + + @Mutation(() => UserSubscriptionType) + 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, + @Args('idempotencyKey') idempotencyKey: string + ) { + return this.service.updateSubscriptionRecurring( + idempotencyKey, + 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 GraphQLError( + 'You are not allowed to access this subscription', + { + extensions: { + status: HttpStatus[HttpStatus.FORBIDDEN], + code: HttpStatus.FORBIDDEN, + }, + } + ); + } + + 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 GraphQLError('You are not allowed to access this invoices', { + extensions: { + status: HttpStatus[HttpStatus.FORBIDDEN], + code: HttpStatus.FORBIDDEN, + }, + }); + } + + return this.db.userInvoice.findMany({ + where: { + userId: user.id, + }, + take, + skip, + orderBy: { + id: 'desc', + }, + }); + } +} 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..e27838e202 --- /dev/null +++ b/packages/backend/server/src/modules/payment/schedule.ts @@ -0,0 +1,238 @@ +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) {} + + 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(e => { + this.logger.error('Failed to retrieve subscription schedule', e); + return undefined; + }); + + return ScheduleManager.create(this.stripe, s); + } else { + return ScheduleManager.create(this.stripe, schedule); + } + } + + async fromSubscription( + idempotencyKey: string, + 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 }, + { idempotencyKey } + ); + + 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(idempotencyKey: string) { + 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', + }, + { idempotencyKey } + ); + } + + async resume(idempotencyKey: string) { + 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', + }, + { idempotencyKey } + ); + } + + async release(idempotencyKey: string) { + if (!this._schedule) { + throw new Error('No schedule'); + } + + await this.stripe.subscriptionSchedules.release(this._schedule.id, { + idempotencyKey, + }); + } + + async update(idempotencyKey: string, 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, { + 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, + }, + ], + }, + { idempotencyKey } + ); + } + } +} 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..71b09f99ce --- /dev/null +++ b/packages/backend/server/src/modules/payment/service.ts @@ -0,0 +1,669 @@ +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'; +import { UsersService } from '../users'; +import { ScheduleManager } from './schedule'; + +const OnEvent = ( + event: Stripe.Event.Type, + opts?: Parameters[1] +) => RawOnEvent(event, opts); + +// Plan x Recurring make a stripe price lookup key +export enum SubscriptionRecurring { + Monthly = 'monthly', + Yearly = 'yearly', +} + +export enum SubscriptionPlan { + Free = 'free', + Pro = 'pro', + Team = 'team', + Enterprise = 'enterprise', +} + +export function encodeLookupKey( + plan: SubscriptionPlan, + recurring: SubscriptionRecurring +): string { + return plan + '_' + recurring; +} + +export function decodeLookupKey( + key: string +): [SubscriptionPlan, SubscriptionRecurring] { + const [plan, recurring] = key.split('_'); + + return [plan as SubscriptionPlan, recurring as SubscriptionRecurring]; +} + +// 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', +} + +export enum CouponType { + EarlyAccess = 'earlyaccess', + EarlyAccessRenew = 'earlyaccessrenew', +} + +@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, + private readonly user: UsersService, + private readonly scheduleManager: ScheduleManager + ) { + 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(); + } + + async createCheckoutSession({ + 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: { + userId: user.id, + }, + }); + + if (currentSubscription && currentSubscription.end < new Date()) { + throw new Error('You already have a subscription'); + } + + const price = await this.getPrice(plan, recurring); + 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, + }, + ], + 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( + idempotencyKey: string, + userId: string + ): Promise { + const user = await this.db.user.findUnique({ + where: { + id: userId, + }, + include: { + subscription: true, + }, + }); + + if (!user?.subscription) { + throw new Error('You do not have any subscription'); + } + + if (user.subscription.canceledAt) { + throw new Error('Your subscription has already been canceled'); + } + + // should release the schedule first + if (user.subscription.stripeScheduleId) { + const manager = await this.scheduleManager.fromSchedule( + user.subscription.stripeScheduleId + ); + await manager.cancel(idempotencyKey); + return this.saveSubscription( + user, + await this.stripe.subscriptions.retrieve( + user.subscription.stripeSubscriptionId + ), + false + ); + } else { + // 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 }, + { idempotencyKey } + ); + return await this.saveSubscription(user, subscription); + } + } + + async resumeCanceledSubscription( + idempotencyKey: string, + userId: string + ): Promise { + const user = await this.db.user.findUnique({ + where: { + id: userId, + }, + include: { + subscription: true, + }, + }); + + if (!user?.subscription) { + throw new Error('You do not have any subscription'); + } + + if (!user.subscription.canceledAt) { + throw new Error('Your subscription has not been canceled'); + } + + if (user.subscription.end < new Date()) { + throw new Error('Your subscription is expired, please checkout again.'); + } + + if (user.subscription.stripeScheduleId) { + const manager = await this.scheduleManager.fromSchedule( + user.subscription.stripeScheduleId + ); + await manager.resume(idempotencyKey); + return this.saveSubscription( + user, + await this.stripe.subscriptions.retrieve( + user.subscription.stripeSubscriptionId + ), + false + ); + } else { + const subscription = await this.stripe.subscriptions.update( + user.subscription.stripeSubscriptionId, + { cancel_at_period_end: false }, + { idempotencyKey } + ); + + return await this.saveSubscription(user, subscription); + } + } + + async updateSubscriptionRecurring( + idempotencyKey: string, + userId: string, + recurring: SubscriptionRecurring + ): Promise { + const user = await this.db.user.findUnique({ + where: { + id: userId, + }, + include: { + subscription: true, + }, + }); + + if (!user?.subscription) { + throw new Error('You do not have any subscription'); + } + + if (user.subscription.canceledAt) { + throw new Error('Your subscription has already been canceled '); + } + + if (user.subscription.recurring === recurring) { + throw new Error('You have already subscribed to this plan'); + } + + const price = await this.getPrice( + user.subscription.plan as SubscriptionPlan, + recurring + ); + + 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 || + 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: manager.schedule?.id ?? null, // update schedule id or set to null(undefined means untouched) + recurring, + }, + }); + } + + 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) { + 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.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 idempotencyKey = stripeInvoice.id + '_earlyaccess'; + const manager = await this.scheduleManager.fromSubscription( + idempotencyKey, + line.subscription as string + ); + await manager.update( + idempotencyKey, + line.price.id, + CouponType.EarlyAccessRenew + ); + } + } + + @OnEvent('invoice.created') + @OnEvent('invoice.finalization_failed') + @OnEvent('invoice.payment_failed') + async saveInvoice(stripeInvoice: Stripe.Invoice) { + if (!stripeInvoice.customer) { + throw new Error('Unexpected invoice with no customer'); + } + + const user = await this.retrieveUserFromCustomer( + typeof stripeInvoice.customer === 'string' + ? stripeInvoice.customer + : stripeInvoice.customer.id + ); + + 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, + link: stripeInvoice.hosted_invoice_url, + }; + + // 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'); + } + + if (!price.lookup_key) { + throw new Error('Unexpected subscription with no key'); + } + + const [plan, recurring] = decodeLookupKey(price.lookup_key); + + await this.db.userInvoice.create({ + data: { + userId: user.id, + stripeInvoiceId: stripeInvoice.id, + plan, + recurring, + reason: stripeInvoice.billing_reason ?? 'contact support', + ...(data as any), + }, + }); + } + } + + private async saveSubscription( + user: User, + subscription: Stripe.Subscription, + fromWebhook = true + ): Promise { + // webhook events may not in sequential order + // always fetch the latest subscription and save + // see https://stripe.com/docs/webhooks#behaviors + if (fromWebhook) { + subscription = await this.stripe.subscriptions.retrieve(subscription.id); + } + + // 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 + ) { + nextBillAt = new Date(subscription.current_period_end * 1000); + } + + const price = subscription.items.data[0].price; + if (!price.lookup_key) { + throw new Error('Unexpected subscription with no key'); + } + + const [plan, recurring] = decodeLookupKey(price.lookup_key); + + 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, + plan, + recurring, + 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( + idempotencyKey: string, + 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 }, + { idempotencyKey } + ); + } + + 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 getPrice( + plan: SubscriptionPlan, + recurring: SubscriptionRecurring + ): Promise { + const prices = await this.stripe.prices.list({ + lookup_keys: [encodeLookupKey(plan, recurring)], + }); + + if (!prices.data.length) { + throw new Error( + `Unknown subscription plan ${plan} with recurring ${recurring}` + ); + } + + return prices.data[0].id; + } + + private async getAvailableCoupon( + user: User, + couponType: CouponType + ): Promise { + 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; + } + } + + return null; + } +} 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..13f692132d --- /dev/null +++ b/packages/backend/server/src/modules/payment/webhook.ts @@ -0,0 +1,64 @@ +import type { RawBodyRequest } from '@nestjs/common'; +import { + Controller, + Logger, + NotAcceptableException, + Post, + Req, +} from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import type { Request } from 'express'; +import Stripe from 'stripe'; + +import { Config } from '../../config'; + +@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 + ) { + this.config = config.payment; + } + + @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.` + ); + + // Stripe requires responseing webhook immediately and handle event asynchronously. + setImmediate(() => { + // handle duplicated events? + // see https://stripe.com/docs/webhooks#handle-duplicate-events + this.event.emitAsync(event.type, event.data.object).catch(e => { + this.logger.error('Failed to handle Stripe Webhook event.', e); + }); + }); + } catch (err) { + this.logger.error('Stripe Webhook error', err); + throw new NotAcceptableException(); + } + } +} 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/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/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({ diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 63d8248ede..dc68b19d1a 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,74 @@ 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 + link: String + createdAt: DateTime! + updatedAt: DateTime! +} + +enum InvoiceStatus { + Draft + Open + Void + Paid + Uncollectible +} + type InviteUserType { """User name""" name: String @@ -166,10 +236,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 +276,15 @@ type Mutation { removeAvatar: RemoveAvatar! deleteAccount: DeleteAccount! addToNewFeaturesWaitingList(type: NewFeaturesKind!, email: String!): AddToNewFeaturesWaitingList! + + """Create a subscription checkout link of stripe""" + checkout(recurring: SubscriptionRecurring!, idempotencyKey: String!): String! + + """Create a stripe customer portal to manage payment methods""" + createCustomerPortal: String! + 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/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/common/env/src/global.ts b/packages/common/env/src/global.ts index 225539e67e..215a94824a 100644 --- a/packages/common/env/src/global.ts +++ b/packages/common/env/src/global.ts @@ -30,6 +30,7 @@ export const runtimeFlagsSchema = z.object({ enableCloud: z.boolean(), enableCaptcha: z.boolean(), enableEnhanceShareMode: z.boolean(), + enablePayment: z.boolean(), // this is for the electron app serverUrlPrefix: z.string(), enableMoveDatabase: z.boolean(), diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index 70cde8a133..263bec7608 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -42,6 +42,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", @@ -74,6 +75,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/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 (
void; + plan: SubscriptionPlan; } -const transformBytesToMB = (bytes: number) => { - return (bytes / 1024 / 1024).toFixed(2); -}; - -const transformBytesToGB = (bytes: number) => { - return (bytes / 1024 / 1024 / 1024).toFixed(2); -}; +enum ButtonType { + Primary = 'primary', + Default = 'default', +} export const StorageProgress = ({ max: upperLimit, value, onUpgrade, + plan, }: StorageProgressProgress) => { const t = useAFFiNEI18N(); const percent = useMemo( @@ -31,51 +32,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 === SubscriptionPlan.Free) { + return ButtonType.Primary; + } + return ButtonType.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} ${t['com.affine.storage.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/.webpack/runtime-config.ts b/packages/frontend/core/.webpack/runtime-config.ts index 109e93c059..1e75ee07b8 100644 --- a/packages/frontend/core/.webpack/runtime-config.ts +++ b/packages/frontend/core/.webpack/runtime-config.ts @@ -31,6 +31,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig { enableCloud: true, enableCaptcha: true, enableEnhanceShareMode: false, + enablePayment: true, serverUrlPrefix: 'https://app.affine.pro', editorFlags, appVersion: packageJson.version, @@ -65,6 +66,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig { enableCloud: true, enableCaptcha: true, enableEnhanceShareMode: false, + enablePayment: true, serverUrlPrefix: 'https://affine.fail', editorFlags, appVersion: packageJson.version, @@ -120,6 +122,11 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig { enableMoveDatabase: process.env.ENABLE_MOVE_DATABASE ? process.env.ENABLE_MOVE_DATABASE === 'true' : currentBuildPreset.enableMoveDatabase, + enablePayment: process.env.ENABLE_PAYMENT + ? process.env.ENABLE_PAYMENT !== 'false' + : buildFlags.mode === 'development' + ? true + : currentBuildPreset.enablePayment, }; if (buildFlags.mode === 'development') { diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index 957d91af11..ef3d772614 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -41,8 +41,9 @@ "@mui/material": "^5.14.14", "@radix-ui/react-select": "^2.0.0", "@react-hookz/web": "^23.1.0", - "@toeverything/components": "^0.0.45", + "@toeverything/components": "^0.0.46", "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/atoms/index.ts b/packages/frontend/core/src/atoms/index.ts index 7ebe1d6b0e..82e911331b 100644 --- a/packages/frontend/core/src/atoms/index.ts +++ b/packages/frontend/core/src/atoms/index.ts @@ -12,6 +12,7 @@ export const openCreateWorkspaceModalAtom = atom(false); export const openQuickSearchModalAtom = atom(false); export const openOnboardingModalAtom = atom(false); export const openSignOutModalAtom = atom(false); +export const openPaymentDisableAtom = atom(false); export type SettingAtom = Pick & { open: boolean; diff --git a/packages/frontend/core/src/components/affine/auth/style.css.ts b/packages/frontend/core/src/components/affine/auth/style.css.ts index 23d814e1e1..a8331464f8 100644 --- a/packages/frontend/core/src/components/affine/auth/style.css.ts +++ b/packages/frontend/core/src/components/affine/auth/style.css.ts @@ -62,3 +62,17 @@ export const accessMessage = style({ marginTop: 65, marginBottom: 40, }); + +export const userPlanButton = style({ + display: 'flex', + fontSize: 'var(--affine-font-xs)', + height: 20, + fontWeight: 500, + cursor: 'pointer', + color: 'var(--affine-pure-white)', + backgroundColor: 'var(--affine-brand-color)', + padding: '0 4px', + borderRadius: 4, + justifyContent: 'center', + alignItems: 'center', +}); 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 new file mode 100644 index 0000000000..580db2ea11 --- /dev/null +++ b/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx @@ -0,0 +1,37 @@ +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'; + +import { openSettingModalAtom } from '../../../atoms'; +import { useUserSubscription } from '../../../hooks/use-subscription'; +import * as styles from './style.css'; + +export const UserPlanButton = () => { + const [subscription] = useUserSubscription(); + const plan = subscription?.plan ?? SubscriptionPlan.Free; + + const setSettingModalAtom = useSetAtom(openSettingModalAtom); + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + setSettingModalAtom({ + open: true, + activeTab: 'plans', + workspaceId: null, + }); + }, + [setSettingModalAtom] + ); + + const t = useAFFiNEI18N(); + + return ( + +
+ {plan} +
+
+ ); +}; 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/core/src/components/affine/payment-disable/index.tsx b/packages/frontend/core/src/components/affine/payment-disable/index.tsx new file mode 100644 index 0000000000..58a29da3c5 --- /dev/null +++ b/packages/frontend/core/src/components/affine/payment-disable/index.tsx @@ -0,0 +1,35 @@ +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { ConfirmModal } from '@toeverything/components/modal'; +import { useAtom } from 'jotai'; +import { useCallback } from 'react'; + +import { openPaymentDisableAtom } from '../../../atoms'; +import * as styles from './style.css'; + +export const PaymentDisableModal = () => { + const [open, setOpen] = useAtom(openPaymentDisableAtom); + const t = useAFFiNEI18N(); + + const onClickCancel = useCallback(() => { + setOpen(false); + }, [setOpen]); + + return ( + +

+ {t['com.affine.payment.disable-payment.description']()} +

+
+ ); +}; diff --git a/packages/frontend/core/src/components/affine/payment-disable/style.css.ts b/packages/frontend/core/src/components/affine/payment-disable/style.css.ts new file mode 100644 index 0000000000..ce218f7e02 --- /dev/null +++ b/packages/frontend/core/src/components/affine/payment-disable/style.css.ts @@ -0,0 +1,5 @@ +import { style } from '@vanilla-extract/css'; + +export const paymentDisableModalContent = style({ + color: 'var(--affine-text-primary-color)', +}); 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..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 @@ -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 +232,7 @@ export const AccountSetting: FC = () => { name={t['com.affine.settings.password']()} desc={t['com.affine.settings.password.message']()} > - + ); +}; + +const PaymentMethodUpdater = () => { + // TODO: open stripe customer portal + const { isMutating, trigger } = useMutation({ + mutation: createCustomerPortalMutation, + }); + const t = useAFFiNEI18N(); + + const update = useCallback(() => { + trigger(null, { + onSuccess: data => { + window.open(data.createCustomerPortal, '_blank', 'noopener noreferrer'); + }, + }); + }, [trigger]); + + return ( + + ); +}; + +const ResumeSubscription = ({ + onSubscriptionUpdate, +}: { + onSubscriptionUpdate: SubscriptionMutator; +}) => { + const t = useAFFiNEI18N(); + const { isMutating, trigger } = useMutation({ + mutation: resumeSubscriptionMutation, + }); + + // allow replay request on network error until component unmount + const idempotencyKey = useMemo(() => nanoid(), []); + + const resume = useCallback(() => { + trigger( + { idempotencyKey }, + { + onSuccess: data => { + onSubscriptionUpdate(data.resumeSubscription); + }, + } + ); + }, [trigger, idempotencyKey, onSubscriptionUpdate]); + + return ( + + ); +}; + +const CancelSubscription = ({ loading }: { loading?: boolean }) => { + return ( + } + disabled={loading} + loading={loading} + /> + ); +}; + +const BillingHistory = () => { + const t = useAFFiNEI18N(); + const { data: invoicesQueryResult } = useQuery({ + query: invoicesQuery, + variables: { + skip: 0, + take: 12, + }, + }); + + const invoices = invoicesQueryResult.currentUser?.invoices ?? []; + + return ( +
+ {invoices.length === 0 ? ( +

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

+ ) : ( + // TODO: pagination + invoices.map(invoice => ( + + )) + )} +
+ ); +}; + +const InvoiceLine = ({ + invoice, +}: { + 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 + ? 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 new file mode 100644 index 0000000000..55243b36dd --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/style.css.ts @@ -0,0 +1,53 @@ +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 billingFrequency = style({ + fontSize: 'var(--affine-font-base)', +}); + +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)', +}); + +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/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 a57f442f1d..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 @@ -7,8 +7,12 @@ 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 { PaymentIcon, UpgradeIcon } from './icons'; +import { AFFiNECloudPlans } from './plans'; import { Plugins } from './plugins'; import { Shortcuts } from './shortcuts'; @@ -16,7 +20,9 @@ export type GeneralSettingKeys = | 'shortcuts' | 'appearance' | 'plugins' - | 'about'; + | 'about' + | 'plans' + | 'billing'; interface GeneralSettingListItem { key: GeneralSettingKeys; @@ -29,8 +35,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'](), @@ -43,6 +50,13 @@ export const useGeneralSettingList = (): GeneralSettingList => { icon: KeyboardIcon, testId: 'shortcuts-panel-trigger', }, + { + key: 'plans', + title: t['com.affine.payment.title'](), + icon: UpgradeIcon, + testId: 'plans-panel-trigger', + }, + { key: 'plugins', title: 'Plugins', @@ -56,6 +70,17 @@ export const useGeneralSettingList = (): GeneralSettingList => { testId: 'about-panel-trigger', }, ]; + + if (status === 'authenticated') { + settings.splice(3, 0, { + key: 'billing', + title: t['com.affine.payment.billing-setting.title'](), + icon: PaymentIcon, + testId: 'billing-panel-trigger', + }); + } + + return settings; }; interface GeneralSettingProps { @@ -72,6 +97,10 @@ export const GeneralSetting = ({ generalKey }: GeneralSettingProps) => { return ; case 'about': return ; + case 'plans': + return ; + case 'billing': + return ; default: return null; } diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/icons/bulled-list.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/icons/bulled-list.tsx new file mode 100644 index 0000000000..edd3db03e9 --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/icons/bulled-list.tsx @@ -0,0 +1,19 @@ +export function BulledListIcon({ color = 'currentColor' }: { color: string }) { + return ( + + + + ); +} 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..4067782bd4 --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx @@ -0,0 +1,196 @@ +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'; +import { useUserSubscription } from '../../../../../hooks/use-subscription'; +import { PlanLayout } from './layout'; +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(t); + const scrollWrapper = useRef(null); + + 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 [recurring, setRecurring] = useState( + subscription?.recurring ?? SubscriptionRecurring.Yearly + ); + + 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 + )?.discount; + + // auto scroll to current plan card + useEffect(() => { + if (!scrollWrapper.current) return; + const currentPlanCard = scrollWrapper.current?.querySelector( + '[data-current="true"]' + ); + const wrapperComputedStyle = getComputedStyle(scrollWrapper.current); + const left = currentPlanCard + ? currentPlanCard.getBoundingClientRect().left - + scrollWrapper.current.getBoundingClientRect().left - + parseInt(wrapperComputedStyle.paddingLeft) + : 0; + const appeared = + scrollWrapper.current.getAttribute('data-appeared') === 'true'; + const animationFrameId = requestAnimationFrame(() => { + scrollWrapper.current?.scrollTo({ + behavior: appeared ? 'smooth' : 'instant', + left, + }); + scrollWrapper.current?.setAttribute('data-appeared', 'true'); + }); + return () => { + cancelAnimationFrame(animationFrameId); + }; + }, [recurring]); + + const subtitle = loggedIn ? ( + 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 + + . + +

+ ) + ) : ( +

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

+ ); + + const tabs = ( + + {Object.values(SubscriptionRecurring).map(recurring => ( + + {getRecurringLabel({ recurring, t })} + {recurring === SubscriptionRecurring.Yearly && yearlyDiscount && ( + + {t['com.affine.payment.discount-amount']({ + amount: yearlyDiscount, + })} + + )} + + ))} + + ); + + const scroll = ( +
+ {Array.from(planDetail.values()).map(detail => { + return ( + { + 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 }} + /> + ); + })} +
+ ); + + return ( + + ); +}; + +export const AFFiNECloudPlans = () => { + return ( + // 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..89388f4066 --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx @@ -0,0 +1,58 @@ +import { SettingHeader } from '@affine/component/setting-components'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +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 = () => { + const t = useAFFiNEI18N(); + + return ( + + {t['com.affine.payment.see-all-plans']()} + {} + + ); +}; + +export const PlanLayout = ({ + subtitle, + tabs, + scroll, + title, + footer = , + scrollRef, +}: PlanLayoutProps) => { + const t = useAFFiNEI18N(); + 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/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 new file mode 100644 index 0000000000..7e7279cb7f --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx @@ -0,0 +1,578 @@ +import type { + Subscription, + SubscriptionMutator, +} from '@affine/core/hooks/use-subscription'; +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 { nanoid } from 'nanoid'; +import { + type PropsWithChildren, + useCallback, + useEffect, + useMemo, + 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 { + 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; + onNotify: (info: { + detail: FixedPrice | DynamicPrice; + recurring: string; + }) => void; +} + +export function getPlanDetail(t: ReturnType) { + return new Map([ + [ + SubscriptionPlan.Free, + { + type: 'fixed', + plan: SubscriptionPlan.Free, + price: '0', + yearlyPrice: '0', + benefits: [ + 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' }), + ], + }, + ], + [ + SubscriptionPlan.Pro, + { + type: 'fixed', + plan: SubscriptionPlan.Pro, + price: '1', + yearlyPrice: '1', + benefits: [ + 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' }), + ], + }, + ], + [ + SubscriptionPlan.Team, + { + type: 'dynamic', + plan: SubscriptionPlan.Team, + contact: true, + benefits: [ + t['com.affine.payment.dynamic-benefit-1'](), + t['com.affine.payment.dynamic-benefit-2'](), + t['com.affine.payment.dynamic-benefit-3'](), + ], + }, + ], + [ + SubscriptionPlan.Enterprise, + { + type: 'dynamic', + plan: SubscriptionPlan.Enterprise, + contact: true, + benefits: [ + t['com.affine.payment.dynamic-benefit-4'](), + t['com.affine.payment.dynamic-benefit-5'](), + ], + }, + ], + ]); +} + +export const PlanCard = (props: PlanCardProps) => { + const { detail, subscription, recurring } = props; + 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 + + )} +

+
+ +
+
+ {detail.benefits.map((content, i) => ( +
+
+ {detail.type == 'dynamic' ? ( + + ) : ( + + )} +
+
{content}
+
+ ))} +
+
+ ); +}; + +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 = () => { + 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, + }); + + // allow replay request on network error until component unmount + const idempotencyKey = useMemo(() => nanoid(), []); + + const downgrade = useCallback(() => { + trigger( + { idempotencyKey }, + { + onSuccess: data => { + onSubscriptionUpdate(data.cancelSubscription); + }, + } + ); + }, [trigger, idempotencyKey, onSubscriptionUpdate]); + + const tooltipContent = disabled + ? t['com.affine.payment.downgraded-tooltip']() + : null; + + return ( + <> + +
+ +
+
+ + + ); +}; + +const ContactSales = () => { + const t = useAFFiNEI18N(); + return ( + + + + ); +}; + +const Upgrade = ({ + recurring, + onSubscriptionUpdate, +}: { + recurring: SubscriptionRecurring; + onSubscriptionUpdate: SubscriptionMutator; +}) => { + const t = useAFFiNEI18N(); + const { isMutating, trigger } = useMutation({ + mutation: checkoutMutation, + }); + + const newTabRef = useRef(null); + + // allow replay request on network error until component unmount + const idempotencyKey = useMemo(() => nanoid(), []); + + const onClose = useCallback(() => { + newTabRef.current = null; + onSubscriptionUpdate(); + }, [onSubscriptionUpdate]); + + const [, openPaymentDisableModal] = useAtom(openPaymentDisableAtom); + const upgrade = useCallback(() => { + if (!runtimeConfig.enablePayment) { + openPaymentDisableModal(true); + return; + } + + if (newTabRef.current) { + newTabRef.current.focus(); + } else { + trigger( + { recurring, idempotencyKey }, + { + 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); + } + }, + } + ); + } + }, [openPaymentDisableModal, trigger, recurring, idempotencyKey, onClose]); + + useEffect(() => { + return () => { + if (newTabRef.current) { + newTabRef.current.removeEventListener('close', onClose); + newTabRef.current = null; + } + }; + }, [onClose]); + + return ( + <> + + + ); +}; + +const ChangeRecurring = ({ + 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, + }); + + // allow replay request on network error until component unmount + const idempotencyKey = useMemo(() => nanoid(), []); + + const change = useCallback(() => { + trigger( + { recurring: to, idempotencyKey }, + { + onSuccess: data => { + onSubscriptionUpdate(data.updateSubscriptionRecurring); + }, + } + ); + }, [trigger, to, idempotencyKey, onSubscriptionUpdate]); + + 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) => { + 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, + }); + + // allow replay request on network error until component unmount + const idempotencyKey = useMemo(() => nanoid(), []); + + const resume = useCallback(() => { + trigger( + { idempotencyKey }, + { + onSuccess: data => { + onSubscriptionUpdate(data.resumeSubscription); + }, + } + ); + }, [trigger, idempotencyKey, onSubscriptionUpdate]); + + 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 new file mode 100644 index 0000000000..a1c5ac5abb --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts @@ -0,0 +1,149 @@ +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)', + fontWeight: 400, +}); + +export const planCardsWrapper = style({ + paddingRight: 'calc(var(--setting-modal-gap-x) + 30px)', + display: 'flex', + gap: '16px', + width: 'fit-content', +}); + +export const planCard = style({ + minHeight: '426px', + minWidth: '258px', + borderRadius: '16px', + padding: '20px', + border: '1px solid var(--affine-border-color)', + position: 'relative', + + selectors: { + '&::before': { + content: '', + position: 'absolute', + right: 'calc(100% + var(--setting-modal-gap-x))', + scrollSnapAlign: 'start', + }, + }, +}); + +export const currentPlanCard = style([ + planCard, + { + borderWidth: '2px', + borderColor: 'var(--affine-primary-color)', + boxShadow: 'var(--affine-shadow-2)', + }, +]); + +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 planPriceWrapper = style({ + minHeight: '28px', + lineHeight: 1, + display: 'flex', + alignItems: 'flex-end', +}); + +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)', + display: 'flex', + flexDirection: 'column', + gap: '8px', +}); + +export const planBenefit = style({ + display: 'flex', + gap: '8px', + lineHeight: '20px', + alignItems: 'normal', + fontSize: '12px', +}); + +export const planBenefitIcon = style({ + display: 'flex', + alignItems: 'center', + height: '20px', +}); + +export const planBenefitText = style({ + display: 'flex', + 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 c34ee751c7..7ed213a44e 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/index.tsx @@ -2,7 +2,8 @@ import { WorkspaceDetailSkeleton } from '@affine/component/setting-components'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { ContactWithUsIcon } from '@blocksuite/icons'; import { Modal, type ModalProps } from '@toeverything/components/modal'; -import { Suspense, useCallback } from 'react'; +import { debounce } from 'lodash-es'; +import { Suspense, useCallback, useEffect, useRef } from 'react'; import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status'; import { AccountSetting } from './account-setting'; @@ -37,6 +38,39 @@ export const SettingModal = ({ const generalSettingList = useGeneralSettingList(); + const modalContentRef = useRef(null); + const modalContentWrapperRef = useRef(null); + + useEffect(() => { + if (!modalProps.open) return; + let animationFrameId: number; + const onResize = debounce(() => { + cancelAnimationFrame(animationFrameId); + animationFrameId = requestAnimationFrame(() => { + if (!modalContentRef.current || !modalContentWrapperRef.current) return; + + const wrapperWidth = modalContentWrapperRef.current.offsetWidth; + const contentWidth = modalContentRef.current.offsetWidth; + + modalContentRef.current?.style.setProperty( + '--setting-modal-width', + `${wrapperWidth}px` + ); + modalContentRef.current?.style.setProperty( + '--setting-modal-gap-x', + `${(wrapperWidth - contentWidth) / 2}px` + ); + }); + }, 200); + window.addEventListener('resize', onResize); + onResize(); + + return () => { + cancelAnimationFrame(animationFrameId); + window.removeEventListener('resize', onResize); + }; + }, [modalProps.open]); + const onGeneralSettingClick = useCallback( (key: GeneralSettingKeys) => { onSettingClick({ @@ -84,32 +118,38 @@ export const SettingModal = ({ onAccountSettingClick={onAccountSettingClick} /> -
-
- {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/setting-sidebar/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/index.tsx index 2719cab279..a2c8744e33 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/index.tsx @@ -20,6 +20,7 @@ import { authAtom } from '../../../../atoms'; import { useCurrentLoginStatus } from '../../../../hooks/affine/use-current-login-status'; import { useCurrentUser } from '../../../../hooks/affine/use-current-user'; import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace'; +import { UserPlanButton } from '../../auth/user-plan-button'; import type { GeneralSettingKeys, GeneralSettingList, @@ -52,9 +53,13 @@ export const UserInfo = ({
-
- {user.name} +
+
+ {user.name} +
+
+
{user.email}
diff --git a/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/style.css.ts b/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/style.css.ts index f867a5b83e..7b812e8f4f 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/style.css.ts +++ b/packages/frontend/core/src/components/affine/setting-modal/setting-sidebar/style.css.ts @@ -94,7 +94,6 @@ export const currentWorkspaceLabel = style({ export const sidebarFooter = style({ padding: '0 16px' }); export const accountButton = style({ - height: '42px', padding: '4px 8px', borderRadius: '8px', cursor: 'pointer', @@ -129,13 +128,21 @@ globalStyle(`${accountButton} .content`, { flexGrow: '1', minWidth: 0, }); +globalStyle(`${accountButton} .name-container`, { + display: 'flex', + justifyContent: 'flex-start', + alignItems: 'center', + width: '100%', + gap: '4px', + height: '22px', +}); globalStyle(`${accountButton} .name`, { fontSize: 'var(--affine-font-sm)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', - flexGrow: 1, + height: '22px', }); globalStyle(`${accountButton} .email`, { fontSize: 'var(--affine-font-xs)', @@ -144,4 +151,5 @@ globalStyle(`${accountButton} .email`, { textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexGrow: 1, + height: '20px', }); 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 527c59ba04..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,22 +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', - overflow: 'hidden auto', - // 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/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.css.ts b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.css.ts index 43275e4318..d2fdb27170 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.css.ts +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.css.ts @@ -3,9 +3,10 @@ import { style } from '@vanilla-extract/css'; export const userAccountContainer = style({ display: 'flex', padding: '4px 0px 4px 12px', - gap: '12px', + gap: '8px', alignItems: 'center', justifyContent: 'space-between', + width: '100%', }); export const userEmail = style({ fontSize: 'var(--affine-font-sm)', @@ -14,5 +15,12 @@ export const userEmail = style({ textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap', - maxWidth: 'calc(100% - 36px)', +}); + +export const leftContainer = style({ + display: 'flex', + alignItems: 'center', + gap: '8px', + width: '100%', + overflow: 'hidden', }); diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.tsx index 9f1a06bbf0..3e959c6690 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.tsx @@ -14,6 +14,7 @@ import { openSettingModalAtom, openSignOutModalAtom, } from '../../../../../atoms'; +import { UserPlanButton } from '../../../../affine/auth/user-plan-button'; import * as styles from './index.css'; const AccountMenu = ({ onEventEnd }: { onEventEnd?: () => void }) => { @@ -73,7 +74,10 @@ export const UserAccountItem = ({ }) => { return (
-
{email}
+
+
{email}
+ +
} contentOptions={{ diff --git a/packages/frontend/core/src/hooks/use-subscription.ts b/packages/frontend/core/src/hooks/use-subscription.ts new file mode 100644 index 0000000000..e2d7d50b12 --- /dev/null +++ b/packages/frontend/core/src/hooks/use-subscription.ts @@ -0,0 +1,40 @@ +import { type SubscriptionQuery, subscriptionQuery } from '@affine/graphql'; +import { useQuery } from '@affine/workspace/affine/gql'; +import { useCallback } from 'react'; + +export type Subscription = NonNullable< + NonNullable['subscription'] +>; + +export type SubscriptionMutator = (update?: Partial) => void; + +const selector = (data: SubscriptionQuery) => + data.currentUser?.subscription ?? null; + +export const useUserSubscription = () => { + const { data, mutate } = useQuery({ + query: subscriptionQuery, + }); + + const set: SubscriptionMutator = useCallback( + (update?: Partial) => { + mutate(prev => { + if (!update || !prev?.currentUser?.subscription) { + return; + } + + return { + currentUser: { + subscription: { + ...prev.currentUser?.subscription, + ...update, + }, + }, + }; + }); + }, + [mutate] + ); + + return [selector(data), set] as const; +}; 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/providers/modal-provider.tsx b/packages/frontend/core/src/providers/modal-provider.tsx index 56f910733e..6a9daa7488 100644 --- a/packages/frontend/core/src/providers/modal-provider.tsx +++ b/packages/frontend/core/src/providers/modal-provider.tsx @@ -12,6 +12,7 @@ import { openSettingModalAtom, openSignOutModalAtom, } from '../atoms'; +import { PaymentDisableModal } from '../components/affine/payment-disable'; import { useCurrentWorkspace } from '../hooks/current/use-current-workspace'; import { useNavigateHelper } from '../hooks/use-navigate-helper'; import { signOutCloud } from '../utils/cloud-utils'; @@ -201,6 +202,7 @@ export const AllWorkspaceModals = (): ReactElement => { + ); }; diff --git a/packages/frontend/core/src/router.ts b/packages/frontend/core/src/router.ts index b48d35ba57..3a974858d2 100644 --- a/packages/frontend/core/src/router.ts +++ b/packages/frontend/core/src/router.ts @@ -56,6 +56,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/graphql/src/graphql/cancel-subscription.gql b/packages/frontend/graphql/src/graphql/cancel-subscription.gql new file mode 100644 index 0000000000..6c791d909c --- /dev/null +++ b/packages/frontend/graphql/src/graphql/cancel-subscription.gql @@ -0,0 +1,8 @@ +mutation cancelSubscription($idempotencyKey: String!) { + cancelSubscription(idempotencyKey: $idempotencyKey) { + 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..5ab82000bb --- /dev/null +++ b/packages/frontend/graphql/src/graphql/create-checkout-link.gql @@ -0,0 +1,6 @@ +mutation checkout( + $recurring: SubscriptionRecurring! + $idempotencyKey: String! +) { + checkout(recurring: $recurring, idempotencyKey: $idempotencyKey) +} 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 f31b3c5c8c..b5e39f3166 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($idempotencyKey: String!) { + cancelSubscription(idempotencyKey: $idempotencyKey) { + id + status + nextBillAt + canceledAt + } +}`, +}; + export const changeEmailMutation = { id: 'changeEmailMutation' as const, operationName: 'changeEmail', @@ -111,6 +127,28 @@ mutation changePassword($token: String!, $newPassword: String!) { }`, }; +export const checkoutMutation = { + id: 'checkoutMutation' as const, + operationName: 'checkout', + definitionName: 'checkout', + containsFile: false, + query: ` +mutation checkout($recurring: SubscriptionRecurring!, $idempotencyKey: String!) { + checkout(recurring: $recurring, idempotencyKey: $idempotencyKey) +}`, +}; + +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', @@ -321,6 +359,30 @@ 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 + link + createdAt + } + } +}`, +}; + export const leaveWorkspaceMutation = { id: 'leaveWorkspaceMutation' as const, operationName: 'leaveWorkspace', @@ -336,6 +398,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', @@ -349,6 +428,23 @@ mutation removeAvatar { }`, }; +export const resumeSubscriptionMutation = { + id: 'resumeSubscriptionMutation' as const, + operationName: 'resumeSubscription', + definitionName: 'resumeSubscription', + containsFile: false, + query: ` +mutation resumeSubscription($idempotencyKey: String!) { + resumeSubscription(idempotencyKey: $idempotencyKey) { + id + status + nextBillAt + start + end + } +}`, +}; + export const revokeMemberPermissionMutation = { id: 'revokeMemberPermissionMutation' as const, operationName: 'revokeMemberPermission', @@ -469,6 +565,47 @@ 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!, $idempotencyKey: String!) { + updateSubscriptionRecurring( + recurring: $recurring + idempotencyKey: $idempotencyKey + ) { + 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..bd26f0896a --- /dev/null +++ b/packages/frontend/graphql/src/graphql/invoices.gql @@ -0,0 +1,16 @@ +query invoices($take: Int!, $skip: Int!) { + currentUser { + invoices(take: $take, skip: $skip) { + id + status + plan + recurring + currency + amount + reason + lastPaymentError + link + 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/resume-subscription.gql b/packages/frontend/graphql/src/graphql/resume-subscription.gql new file mode 100644 index 0000000000..c060e25059 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/resume-subscription.gql @@ -0,0 +1,9 @@ +mutation resumeSubscription($idempotencyKey: String!) { + resumeSubscription(idempotencyKey: $idempotencyKey) { + id + status + nextBillAt + start + end + } +} 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..1957efbfa3 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/update-subscription-billing.gql @@ -0,0 +1,14 @@ +mutation updateSubscription( + $recurring: SubscriptionRecurring! + $idempotencyKey: String! +) { + updateSubscriptionRecurring( + recurring: $recurring + idempotencyKey: $idempotencyKey + ) { + id + plan + recurring + nextBillAt + } +} diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 1a47becf06..07e606e7f2 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 */ @@ -99,6 +130,21 @@ export type AllBlobSizesQuery = { collectAllBlobSizes: { __typename?: 'WorkspaceBlobSizes'; size: number }; }; +export type CancelSubscriptionMutationVariables = Exact<{ + idempotencyKey: Scalars['String']['input']; +}>; + +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']; }>; @@ -130,6 +176,22 @@ export type ChangePasswordMutation = { }; }; +export type CheckoutMutationVariables = Exact<{ + recurring: SubscriptionRecurring; + idempotencyKey: Scalars['String']['input']; +}>; + +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']; }>; @@ -173,7 +235,7 @@ export type GetCurrentUserQuery = { avatarUrl: string | null; createdAt: string | null; token: { __typename?: 'TokenType'; sessionToken: string | null }; - }; + } | null; }; export type GetInviteInfoQueryVariables = Exact<{ @@ -297,6 +359,31 @@ 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; + link: string | null; + createdAt: string; + }>; + } | null; +}; + export type LeaveWorkspaceMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; workspaceName: Scalars['String']['input']; @@ -308,6 +395,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 = { @@ -315,6 +416,22 @@ export type RemoveAvatarMutation = { removeAvatar: { __typename?: 'RemoveAvatar'; success: boolean }; }; +export type ResumeSubscriptionMutationVariables = Exact<{ + idempotencyKey: Scalars['String']['input']; +}>; + +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']; @@ -420,6 +537,42 @@ 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; + idempotencyKey: Scalars['String']['input']; +}>; + +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']; }>; @@ -539,6 +692,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 = @@ -552,6 +720,11 @@ export type Mutations = variables: SetBlobMutationVariables; response: SetBlobMutation; } + | { + name: 'cancelSubscriptionMutation'; + variables: CancelSubscriptionMutationVariables; + response: CancelSubscriptionMutation; + } | { name: 'changeEmailMutation'; variables: ChangeEmailMutationVariables; @@ -562,6 +735,16 @@ export type Mutations = variables: ChangePasswordMutationVariables; response: ChangePasswordMutation; } + | { + name: 'checkoutMutation'; + variables: CheckoutMutationVariables; + response: CheckoutMutation; + } + | { + name: 'createCustomerPortalMutation'; + variables: CreateCustomerPortalMutationVariables; + response: CreateCustomerPortalMutation; + } | { name: 'createWorkspaceMutation'; variables: CreateWorkspaceMutationVariables; @@ -587,6 +770,11 @@ export type Mutations = variables: RemoveAvatarMutationVariables; response: RemoveAvatarMutation; } + | { + name: 'resumeSubscriptionMutation'; + variables: ResumeSubscriptionMutationVariables; + response: ResumeSubscriptionMutation; + } | { name: 'revokeMemberPermissionMutation'; variables: RevokeMemberPermissionMutationVariables; @@ -637,6 +825,11 @@ export type Mutations = variables: SignUpMutationVariables; response: SignUpMutation; } + | { + name: 'updateSubscriptionMutation'; + variables: UpdateSubscriptionMutationVariables; + response: UpdateSubscriptionMutation; + } | { name: 'uploadAvatarMutation'; variables: UploadAvatarMutationVariables; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 0036a2935f..7783794f99 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -343,7 +343,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", @@ -646,6 +648,8 @@ "com.affine.cmdk.affine.getting-started": "Getting Started", "com.affine.cmdk.affine.contact-us": "Contact Us", "com.affine.cmdk.affine.restart-to-upgrade": "Restart to Upgrade", + "com.affine.payment.disable-payment.title": "Account Upgrade Unavailable", + "com.affine.payment.disable-payment.description": "This is a special testing(Canary) version of AFFiNE. Account upgrades are not supported in this version. If you want to experience the full service, please download the stable version from our website.", "com.affine.share-menu.publish-to-web": "Publish to Web", "com.affine.share-menu.publish-to-web.description": "Let anyone with a link view a read-only version of this page.", "com.affine.share-menu.share-privately": "Share Privately", @@ -655,6 +659,86 @@ "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.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", + "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}}", "com.affine.cmdk.affine.switch-state.on": "ON", "com.affine.cmdk.affine.switch-state.off": "OFF" } diff --git a/packages/plugins/copilot/package.json b/packages/plugins/copilot/package.json index ed129e7682..6b0d4f592e 100644 --- a/packages/plugins/copilot/package.json +++ b/packages/plugins/copilot/package.json @@ -17,7 +17,7 @@ "@affine/component": "workspace:*", "@affine/sdk": "workspace:*", "@blocksuite/icons": "2.1.35", - "@toeverything/components": "^0.0.45", + "@toeverything/components": "^0.0.46", "@vanilla-extract/css": "^1.13.0", "clsx": "^2.0.0", "idb": "^7.1.1", diff --git a/packages/plugins/hello-world/package.json b/packages/plugins/hello-world/package.json index 5911a31375..837afbb78e 100644 --- a/packages/plugins/hello-world/package.json +++ b/packages/plugins/hello-world/package.json @@ -18,7 +18,7 @@ "@affine/component": "workspace:*", "@affine/sdk": "workspace:*", "@blocksuite/icons": "2.1.35", - "@toeverything/components": "^0.0.45" + "@toeverything/components": "^0.0.46" }, "devDependencies": { "@affine/plugin-cli": "workspace:*" diff --git a/packages/plugins/image-preview/package.json b/packages/plugins/image-preview/package.json index c7dab6b7c2..b24320de31 100644 --- a/packages/plugins/image-preview/package.json +++ b/packages/plugins/image-preview/package.json @@ -17,7 +17,7 @@ "@affine/component": "workspace:*", "@affine/sdk": "workspace:*", "@blocksuite/icons": "2.1.35", - "@toeverything/components": "^0.0.45", + "@toeverything/components": "^0.0.46", "@toeverything/theme": "^0.7.20", "clsx": "^2.0.0", "foxact": "^0.2.20", diff --git a/packages/plugins/outline/package.json b/packages/plugins/outline/package.json index faa555f4bf..5e006a2e9f 100644 --- a/packages/plugins/outline/package.json +++ b/packages/plugins/outline/package.json @@ -18,7 +18,7 @@ "@affine/component": "workspace:*", "@affine/sdk": "workspace:*", "@blocksuite/icons": "2.1.35", - "@toeverything/components": "^0.0.45" + "@toeverything/components": "^0.0.46" }, "devDependencies": { "@affine/plugin-cli": "workspace:*", diff --git a/yarn.lock b/yarn.lock index e63c9b736c..6bcdfb25fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -225,12 +225,14 @@ __metadata: "@toeverything/hooks": "workspace:*" "@toeverything/infra": "workspace:*" "@toeverything/theme": "npm:^0.7.20" + "@types/bytes": "npm:^3.1.3" "@types/react": "npm:^18.2.28" "@types/react-datepicker": "npm:^4.19.0" "@types/react-dnd": "npm:^3.0.2" "@types/react-dom": "npm:^18.2.13" "@vanilla-extract/css": "npm:^1.13.0" "@vanilla-extract/dynamic": "npm:^2.0.3" + bytes: "npm:^3.1.2" check-password-strength: "npm:^2.0.7" clsx: "npm:^2.0.0" dayjs: "npm:^1.11.10" @@ -275,7 +277,7 @@ __metadata: "@affine/plugin-cli": "workspace:*" "@affine/sdk": "workspace:*" "@blocksuite/icons": "npm:2.1.35" - "@toeverything/components": "npm:^0.0.45" + "@toeverything/components": "npm:^0.0.46" "@types/marked": "npm:^6.0.0" "@vanilla-extract/css": "npm:^1.13.0" clsx: "npm:^2.0.0" @@ -329,10 +331,12 @@ __metadata: "@sentry/webpack-plugin": "npm:^2.8.0" "@svgr/webpack": "npm:^8.1.0" "@swc/core": "npm:^1.3.93" - "@toeverything/components": "npm:^0.0.45" + "@toeverything/components": "npm:^0.0.46" + "@types/bytes": "npm:^3.1.3" "@types/lodash-es": "npm:^4.17.9" "@types/webpack-env": "npm:^1.18.2" async-call-rpc: "npm:^6.3.1" + bytes: "npm:^3.1.2" copy-webpack-plugin: "npm:^11.0.0" css-loader: "npm:^6.8.1" css-spring: "npm:^4.1.0" @@ -490,7 +494,7 @@ __metadata: "@affine/plugin-cli": "workspace:*" "@affine/sdk": "workspace:*" "@blocksuite/icons": "npm:2.1.35" - "@toeverything/components": "npm:^0.0.45" + "@toeverything/components": "npm:^0.0.46" languageName: unknown linkType: soft @@ -516,7 +520,7 @@ __metadata: "@affine/plugin-cli": "workspace:*" "@affine/sdk": "workspace:*" "@blocksuite/icons": "npm:2.1.35" - "@toeverything/components": "npm:^0.0.45" + "@toeverything/components": "npm:^0.0.46" "@toeverything/theme": "npm:^0.7.20" clsx: "npm:^2.0.0" foxact: "npm:^0.2.20" @@ -617,7 +621,7 @@ __metadata: "@affine/plugin-cli": "workspace:*" "@affine/sdk": "workspace:*" "@blocksuite/icons": "npm:2.1.35" - "@toeverything/components": "npm:^0.0.45" + "@toeverything/components": "npm:^0.0.46" jotai: "npm:^2.4.3" react: "npm:18.2.0" react-dom: "npm:18.2.0" @@ -675,6 +679,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" @@ -740,6 +745,7 @@ __metadata: semver: "npm:^7.5.4" sinon: "npm:^16.1.0" socket.io: "npm:^4.7.2" + stripe: "npm:^14.1.0" supertest: "npm:^6.3.3" ts-node: "npm:^10.9.1" typescript: "npm:^5.2.2" @@ -7333,6 +7339,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" @@ -12580,9 +12599,9 @@ __metadata: languageName: node linkType: hard -"@toeverything/components@npm:^0.0.45": - version: 0.0.45 - resolution: "@toeverything/components@npm:0.0.45" +"@toeverything/components@npm:^0.0.46": + version: 0.0.46 + resolution: "@toeverything/components@npm:0.0.46" dependencies: "@blocksuite/icons": "npm:^2.1.33" "@radix-ui/react-dialog": "npm:^1.0.4" @@ -12594,7 +12613,7 @@ __metadata: clsx: ^2 react: ^18 react-dom: ^18 - checksum: fb168a83cd04da654ae1723098bf6902bf0eef8be9410a0814514b062d87e5fd2b972746a7770f8ca831a69d4a53255c87750e9fa8beab040d88deebc646dd92 + checksum: 1e74a620d82bc6f6c318ccac35a2f4f86d5e3b97761e798d0ad3f3b31a74f377e402338ad00a2af54c62dda7d88c231cc07b53f2aa9c9bff1de5088659e1c712 languageName: node linkType: hard @@ -12894,6 +12913,13 @@ __metadata: languageName: node linkType: hard +"@types/bytes@npm:^3.1.3": + version: 3.1.3 + resolution: "@types/bytes@npm:3.1.3" + checksum: c636b74d5a6f4925f1030382d8afcfe271e329da7c8103d175a874688118b60b0b1523e23477554e29456d024e5a180df1ba99d88c49b3a51433b714acffdac9 + languageName: node + linkType: hard + "@types/cacheable-request@npm:^6.0.1": version: 6.0.3 resolution: "@types/cacheable-request@npm:6.0.3" @@ -13485,6 +13511,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" @@ -16387,7 +16420,7 @@ __metadata: languageName: node linkType: hard -"bytes@npm:3.1.2": +"bytes@npm:3.1.2, bytes@npm:^3.1.2": version: 3.1.2 resolution: "bytes@npm:3.1.2" checksum: a10abf2ba70c784471d6b4f58778c0beeb2b5d405148e66affa91f23a9f13d07603d0a0354667310ae1d6dc141474ffd44e2a074be0f6e2254edb8fc21445388 @@ -20296,6 +20329,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" @@ -32291,6 +32331,16 @@ __metadata: languageName: node linkType: hard +"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: 1363bc6fed873a9d96312a7d33fb422b9e0ca32064cf027eee12cf5cd132a936d3e248013b59d7011ca5a40acaae9adfcbae175345f7c76ee778800ca1077756 + languageName: node + linkType: hard + "strnum@npm:^1.0.5": version: 1.0.5 resolution: "strnum@npm:1.0.5"