From df054ac7f6bb0551b62cc2d3a5f1ced80eb600db Mon Sep 17 00:00:00 2001
From: forehalo
Date: Thu, 19 Oct 2023 10:06:34 +0800
Subject: [PATCH 01/27] feat(core): payment backend
---
packages/backend/server/.env.example | 4 +-
.../20231018074747_payment/migration.sql | 68 +++
packages/backend/server/package.json | 2 +
packages/backend/server/schema.prisma | 65 ++
packages/backend/server/src/config/def.ts | 9 +
packages/backend/server/src/config/default.ts | 11 +
packages/backend/server/src/index.ts | 1 +
packages/backend/server/src/modules/index.ts | 14 +-
.../server/src/modules/payment/index.ts | 17 +
.../server/src/modules/payment/resolver.ts | 246 ++++++++
.../server/src/modules/payment/service.ts | 576 ++++++++++++++++++
.../server/src/modules/payment/stripe.ts | 18 +
.../server/src/modules/payment/webhook.ts | 75 +++
.../server/src/modules/users/resolver.ts | 10 +-
packages/backend/server/src/schema.gql | 78 ++-
packages/backend/server/tests/user.e2e.ts | 2 +-
packages/frontend/graphql/src/schema.ts | 33 +-
yarn.lock | 39 ++
18 files changed, 1260 insertions(+), 8 deletions(-)
create mode 100644 packages/backend/server/migrations/20231018074747_payment/migration.sql
create mode 100644 packages/backend/server/src/modules/payment/index.ts
create mode 100644 packages/backend/server/src/modules/payment/resolver.ts
create mode 100644 packages/backend/server/src/modules/payment/service.ts
create mode 100644 packages/backend/server/src/modules/payment/stripe.ts
create mode 100644 packages/backend/server/src/modules/payment/webhook.ts
diff --git a/packages/backend/server/.env.example b/packages/backend/server/.env.example
index 55d11ef5fb..749873dc40 100644
--- a/packages/backend/server/.env.example
+++ b/packages/backend/server/.env.example
@@ -3,4 +3,6 @@ NEXTAUTH_URL="http://localhost:8080"
OAUTH_EMAIL_SENDER="noreply@toeverything.info"
OAUTH_EMAIL_LOGIN=""
OAUTH_EMAIL_PASSWORD=""
-ENABLE_LOCAL_EMAIL="true"
\ No newline at end of file
+ENABLE_LOCAL_EMAIL="true"
+STRIPE_API_KEY=
+STRIPE_WEBHOOK_KEY=
diff --git a/packages/backend/server/migrations/20231018074747_payment/migration.sql b/packages/backend/server/migrations/20231018074747_payment/migration.sql
new file mode 100644
index 0000000000..abd017259d
--- /dev/null
+++ b/packages/backend/server/migrations/20231018074747_payment/migration.sql
@@ -0,0 +1,68 @@
+-- CreateTable
+CREATE TABLE "user_stripe_customers" (
+ "user_id" VARCHAR NOT NULL,
+ "stripe_customer_id" VARCHAR NOT NULL,
+ "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "user_stripe_customers_pkey" PRIMARY KEY ("user_id")
+);
+
+-- CreateTable
+CREATE TABLE "user_subscriptions" (
+ "id" SERIAL NOT NULL,
+ "user_id" VARCHAR(36) NOT NULL,
+ "plan" VARCHAR(20) NOT NULL,
+ "recurring" VARCHAR(20) NOT NULL,
+ "stripe_subscription_id" TEXT NOT NULL,
+ "status" VARCHAR(20) NOT NULL,
+ "start" TIMESTAMPTZ(6) NOT NULL,
+ "end" TIMESTAMPTZ(6) NOT NULL,
+ "next_bill_at" TIMESTAMPTZ(6),
+ "canceled_at" TIMESTAMPTZ(6),
+ "trial_start" TIMESTAMPTZ(6),
+ "trial_end" TIMESTAMPTZ(6),
+ "stripe_schedule_id" VARCHAR,
+ "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ(6) NOT NULL,
+
+ CONSTRAINT "user_subscriptions_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "user_invoices" (
+ "id" SERIAL NOT NULL,
+ "user_id" VARCHAR(36) NOT NULL,
+ "stripe_invoice_id" TEXT NOT NULL,
+ "currency" VARCHAR(3) NOT NULL,
+ "amount" INTEGER NOT NULL,
+ "status" VARCHAR(20) NOT NULL,
+ "plan" VARCHAR(20) NOT NULL,
+ "recurring" VARCHAR(20) NOT NULL,
+ "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ(6) NOT NULL,
+ "reason" VARCHAR NOT NULL,
+ "last_payment_error" TEXT,
+
+ CONSTRAINT "user_invoices_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "user_stripe_customers_stripe_customer_id_key" ON "user_stripe_customers"("stripe_customer_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "user_subscriptions_user_id_key" ON "user_subscriptions"("user_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "user_subscriptions_stripe_subscription_id_key" ON "user_subscriptions"("stripe_subscription_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "user_invoices_stripe_invoice_id_key" ON "user_invoices"("stripe_invoice_id");
+
+-- AddForeignKey
+ALTER TABLE "user_stripe_customers" ADD CONSTRAINT "user_stripe_customers_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "user_subscriptions" ADD CONSTRAINT "user_subscriptions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "user_invoices" ADD CONSTRAINT "user_invoices_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json
index 2567bd6c9d..2571db2fb4 100644
--- a/packages/backend/server/package.json
+++ b/packages/backend/server/package.json
@@ -25,6 +25,7 @@
"@nestjs/apollo": "^12.0.9",
"@nestjs/common": "^10.2.7",
"@nestjs/core": "^10.2.7",
+ "@nestjs/event-emitter": "^2.0.2",
"@nestjs/graphql": "^12.0.9",
"@nestjs/platform-express": "^10.2.7",
"@nestjs/platform-socket.io": "^10.2.7",
@@ -71,6 +72,7 @@
"rxjs": "^7.8.1",
"semver": "^7.5.4",
"socket.io": "^4.7.2",
+ "stripe": "^13.6.0",
"ws": "^8.14.2",
"yjs": "^13.6.8"
},
diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma
index 70c6edf834..bea9b7f8d5 100644
--- a/packages/backend/server/schema.prisma
+++ b/packages/backend/server/schema.prisma
@@ -49,6 +49,9 @@ model User {
/// Not available if user signed up through OAuth providers
password String? @db.VarChar
features UserFeatureGates[]
+ customer UserStripeCustomer?
+ subscription UserSubscription?
+ invoices UserInvoice[]
@@map("users")
}
@@ -164,3 +167,65 @@ model NewFeaturesWaitingList {
@@map("new_features_waiting_list")
}
+
+model UserStripeCustomer {
+ userId String @id @map("user_id") @db.VarChar
+ stripeCustomerId String @unique @map("stripe_customer_id") @db.VarChar
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@map("user_stripe_customers")
+}
+
+model UserSubscription {
+ id Int @id @default(autoincrement()) @db.Integer
+ userId String @unique @map("user_id") @db.VarChar(36)
+ plan String @db.VarChar(20)
+ // yearly/monthly
+ recurring String @db.VarChar(20)
+ // subscription.id
+ stripeSubscriptionId String @unique @map("stripe_subscription_id")
+ // subscription.status, active/past_due/canceled/unpaid...
+ status String @db.VarChar(20)
+ // subscription.current_period_start
+ start DateTime @map("start") @db.Timestamptz(6)
+ // subscription.current_period_end
+ end DateTime @map("end") @db.Timestamptz(6)
+ // subscription.billing_cycle_anchor
+ nextBillAt DateTime? @map("next_bill_at") @db.Timestamptz(6)
+ // subscription.canceled_at
+ canceledAt DateTime? @map("canceled_at") @db.Timestamptz(6)
+ // subscription.trial_start
+ trialStart DateTime? @map("trial_start") @db.Timestamptz(6)
+ // subscription.trial_end
+ trialEnd DateTime? @map("trial_end") @db.Timestamptz(6)
+ stripeScheduleId String? @map("stripe_schedule_id") @db.VarChar
+
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@map("user_subscriptions")
+}
+
+model UserInvoice {
+ id Int @id @default(autoincrement()) @db.Integer
+ userId String @map("user_id") @db.VarChar(36)
+ stripeInvoiceId String @unique @map("stripe_invoice_id")
+ currency String @db.VarChar(3)
+ // CNY 12.50 stored as 1250
+ amount Int @db.Integer
+ status String @db.VarChar(20)
+ plan String @db.VarChar(20)
+ recurring String @db.VarChar(20)
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
+ // billing reason
+ reason String @db.VarChar
+ lastPaymentError String? @map("last_payment_error") @db.Text
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@map("user_invoices")
+}
diff --git a/packages/backend/server/src/config/def.ts b/packages/backend/server/src/config/def.ts
index 345b3942e1..b7d1493d4a 100644
--- a/packages/backend/server/src/config/def.ts
+++ b/packages/backend/server/src/config/def.ts
@@ -363,4 +363,13 @@ export interface AFFiNEConfig {
experimentalMergeWithJwstCodec: boolean;
};
};
+
+ payment: {
+ stripe: {
+ keys: {
+ APIKey: string;
+ webhookKey: string;
+ };
+ } & import('stripe').Stripe.StripeConfig;
+ };
}
diff --git a/packages/backend/server/src/config/default.ts b/packages/backend/server/src/config/default.ts
index b23b915f79..ae46469847 100644
--- a/packages/backend/server/src/config/default.ts
+++ b/packages/backend/server/src/config/default.ts
@@ -89,6 +89,8 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
'boolean',
],
ENABLE_LOCAL_EMAIL: ['auth.localEmail', 'boolean'],
+ STRIPE_API_KEY: 'payment.stripe.keys.APIKey',
+ STRIPE_WEBHOOK_KEY: 'payment.stripe.keys.webhookKey',
} satisfies AFFiNEConfig['ENV_MAP'],
affineEnv: 'dev',
get affine() {
@@ -207,6 +209,15 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
experimentalMergeWithJwstCodec: false,
},
},
+ payment: {
+ stripe: {
+ keys: {
+ APIKey: '',
+ webhookKey: '',
+ },
+ apiVersion: '2023-08-16',
+ },
+ },
} satisfies AFFiNEConfig;
applyEnvToConfig(defaultConfig);
diff --git a/packages/backend/server/src/index.ts b/packages/backend/server/src/index.ts
index 4b83dbd446..446b82c60d 100644
--- a/packages/backend/server/src/index.ts
+++ b/packages/backend/server/src/index.ts
@@ -59,6 +59,7 @@ if (NODE_ENV === 'production') {
const app = await NestFactory.create(AppModule, {
cors: true,
+ rawBody: true,
bodyParser: true,
logger:
NODE_ENV !== 'production' || AFFINE_ENV !== 'production'
diff --git a/packages/backend/server/src/modules/index.ts b/packages/backend/server/src/modules/index.ts
index c11e3428b1..600c5154b2 100644
--- a/packages/backend/server/src/modules/index.ts
+++ b/packages/backend/server/src/modules/index.ts
@@ -1,8 +1,10 @@
import { DynamicModule, Type } from '@nestjs/common';
+import { EventEmitterModule } from '@nestjs/event-emitter';
import { GqlModule } from '../graphql.module';
import { AuthModule } from './auth';
import { DocModule } from './doc';
+import { PaymentModule } from './payment';
import { SyncModule } from './sync';
import { UsersModule } from './users';
import { WorkspaceModule } from './workspaces';
@@ -17,22 +19,30 @@ switch (SERVER_FLAVOR) {
break;
case 'graphql':
BusinessModules.push(
+ EventEmitterModule.forRoot({
+ global: true,
+ }),
GqlModule,
WorkspaceModule,
UsersModule,
AuthModule,
- DocModule.forRoot()
+ DocModule.forRoot(),
+ PaymentModule
);
break;
case 'allinone':
default:
BusinessModules.push(
+ EventEmitterModule.forRoot({
+ global: true,
+ }),
GqlModule,
WorkspaceModule,
UsersModule,
AuthModule,
SyncModule,
- DocModule.forRoot()
+ DocModule.forRoot(),
+ PaymentModule
);
break;
}
diff --git a/packages/backend/server/src/modules/payment/index.ts b/packages/backend/server/src/modules/payment/index.ts
new file mode 100644
index 0000000000..1a51678848
--- /dev/null
+++ b/packages/backend/server/src/modules/payment/index.ts
@@ -0,0 +1,17 @@
+import { Module } from '@nestjs/common';
+
+import { SubscriptionResolver, UserSubscriptionResolver } from './resolver';
+import { SubscriptionService } from './service';
+import { StripeProvider } from './stripe';
+import { StripeWebhook } from './webhook';
+
+@Module({
+ providers: [
+ StripeProvider,
+ SubscriptionService,
+ SubscriptionResolver,
+ UserSubscriptionResolver,
+ ],
+ controllers: [StripeWebhook],
+})
+export class PaymentModule {}
diff --git a/packages/backend/server/src/modules/payment/resolver.ts b/packages/backend/server/src/modules/payment/resolver.ts
new file mode 100644
index 0000000000..e5e1a77a3c
--- /dev/null
+++ b/packages/backend/server/src/modules/payment/resolver.ts
@@ -0,0 +1,246 @@
+import {
+ BadGatewayException,
+ ForbiddenException,
+ InternalServerErrorException,
+} from '@nestjs/common';
+import {
+ Args,
+ Field,
+ Int,
+ Mutation,
+ ObjectType,
+ Parent,
+ Query,
+ registerEnumType,
+ ResolveField,
+ Resolver,
+} from '@nestjs/graphql';
+import type { User, UserInvoice, UserSubscription } from '@prisma/client';
+
+import { Config } from '../../config';
+import { PrismaService } from '../../prisma';
+import { Auth, CurrentUser, Public } from '../auth';
+import { UserType } from '../users';
+import {
+ InvoiceStatus,
+ SubscriptionPlan,
+ SubscriptionRecurring,
+ SubscriptionService,
+ SubscriptionStatus,
+} from './service';
+
+registerEnumType(SubscriptionStatus, { name: 'SubscriptionStatus' });
+registerEnumType(SubscriptionRecurring, { name: 'SubscriptionRecurring' });
+registerEnumType(SubscriptionPlan, { name: 'SubscriptionPlan' });
+registerEnumType(InvoiceStatus, { name: 'InvoiceStatus' });
+
+@ObjectType()
+class SubscriptionPrice {
+ @Field(() => String)
+ type!: 'fixed';
+
+ @Field(() => SubscriptionPlan)
+ plan!: SubscriptionPlan;
+
+ @Field()
+ currency!: string;
+
+ @Field()
+ amount!: number;
+
+ @Field()
+ yearlyAmount!: number;
+}
+
+@ObjectType('UserSubscription')
+class UserSubscriptionType implements Partial {
+ @Field({ name: 'id' })
+ stripeSubscriptionId!: string;
+
+ @Field(() => SubscriptionPlan)
+ plan!: SubscriptionPlan;
+
+ @Field(() => SubscriptionRecurring)
+ recurring!: SubscriptionRecurring;
+
+ @Field(() => SubscriptionStatus)
+ status!: SubscriptionStatus;
+
+ @Field(() => Date)
+ start!: Date;
+
+ @Field(() => Date)
+ end!: Date;
+
+ @Field(() => Date, { nullable: true })
+ trialStart?: Date | null;
+
+ @Field(() => Date, { nullable: true })
+ trialEnd?: Date | null;
+
+ @Field(() => Date, { nullable: true })
+ nextBillAt?: Date | null;
+
+ @Field(() => Date, { nullable: true })
+ canceledAt?: Date | null;
+
+ @Field(() => Date)
+ createdAt!: Date;
+
+ @Field(() => Date)
+ updatedAt!: Date;
+}
+
+@ObjectType('UserInvoice')
+class UserInvoiceType implements Partial {
+ @Field({ name: 'id' })
+ stripeInvoiceId!: string;
+
+ @Field(() => SubscriptionPlan)
+ plan!: SubscriptionPlan;
+
+ @Field(() => SubscriptionRecurring)
+ recurring!: SubscriptionRecurring;
+
+ @Field()
+ currency!: string;
+
+ @Field()
+ amount!: number;
+
+ @Field(() => InvoiceStatus)
+ status!: InvoiceStatus;
+
+ @Field()
+ reason!: string;
+
+ @Field(() => String, { nullable: true })
+ lastPaymentError?: string | null;
+
+ @Field(() => Date)
+ createdAt!: Date;
+
+ @Field(() => Date)
+ updatedAt!: Date;
+}
+
+@Auth()
+@Resolver(() => UserSubscriptionType)
+export class SubscriptionResolver {
+ constructor(
+ private readonly service: SubscriptionService,
+ private readonly config: Config
+ ) {}
+
+ @Public()
+ @Query(() => [SubscriptionPrice])
+ async prices(): Promise {
+ const prices = await this.service.listPrices();
+
+ const yearly = prices.data.find(
+ price => price.lookup_key === SubscriptionRecurring.Yearly
+ );
+ const monthly = prices.data.find(
+ price => price.lookup_key === SubscriptionRecurring.Monthly
+ );
+
+ if (!yearly || !monthly) {
+ throw new BadGatewayException('The prices are not configured correctly');
+ }
+
+ return [
+ {
+ type: 'fixed',
+ plan: SubscriptionPlan.Pro,
+ currency: monthly.currency,
+ amount: monthly.unit_amount ?? 0,
+ yearlyAmount: yearly.unit_amount ?? 0,
+ },
+ ];
+ }
+
+ @Mutation(() => String, {
+ description: 'Create a subscription checkout link of stripe',
+ })
+ async checkout(
+ @CurrentUser() user: User,
+ @Args({ name: 'recurring', type: () => SubscriptionRecurring })
+ recurring: SubscriptionRecurring
+ ) {
+ const session = await this.service.createCheckoutSession({
+ user,
+ recurring,
+ // TODO: replace with frontend url
+ redirectUrl: `${this.config.baseUrl}/api/stripe/success`,
+ });
+
+ if (!session.url) {
+ throw new InternalServerErrorException(
+ 'Failed to create checkout session'
+ );
+ }
+
+ return session.url;
+ }
+
+ @Mutation(() => UserSubscriptionType)
+ async cancelSubscription(@CurrentUser() user: User) {
+ return this.service.cancelSubscription(user.id);
+ }
+
+ @Mutation(() => UserSubscriptionType)
+ async resumeSubscription(@CurrentUser() user: User) {
+ return this.service.resumeCanceledSubscriptin(user.id);
+ }
+
+ @Mutation(() => UserSubscriptionType)
+ async updateSubscriptionRecurring(
+ @CurrentUser() user: User,
+ @Args({ name: 'recurring', type: () => SubscriptionRecurring })
+ recurring: SubscriptionRecurring
+ ) {
+ return this.service.updateSubscriptionRecurring(user.id, recurring);
+ }
+}
+
+@Resolver(() => UserType)
+export class UserSubscriptionResolver {
+ constructor(private readonly db: PrismaService) {}
+
+ @ResolveField(() => UserSubscriptionType, { nullable: true })
+ async subscription(@CurrentUser() me: User, @Parent() user: User) {
+ if (me.id !== user.id) {
+ throw new ForbiddenException();
+ }
+
+ return this.db.userSubscription.findUnique({
+ where: {
+ userId: user.id,
+ },
+ });
+ }
+
+ @ResolveField(() => [UserInvoiceType])
+ async invoices(
+ @CurrentUser() me: User,
+ @Parent() user: User,
+ @Args('take', { type: () => Int, nullable: true, defaultValue: 8 })
+ take: number,
+ @Args('skip', { type: () => Int, nullable: true }) skip?: number
+ ) {
+ if (me.id !== user.id) {
+ throw new ForbiddenException();
+ }
+
+ return this.db.userInvoice.findMany({
+ where: {
+ userId: user.id,
+ },
+ take,
+ skip,
+ orderBy: {
+ id: 'desc',
+ },
+ });
+ }
+}
diff --git a/packages/backend/server/src/modules/payment/service.ts b/packages/backend/server/src/modules/payment/service.ts
new file mode 100644
index 0000000000..8f9771b1bd
--- /dev/null
+++ b/packages/backend/server/src/modules/payment/service.ts
@@ -0,0 +1,576 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { OnEvent as RawOnEvent } from '@nestjs/event-emitter';
+import type {
+ Prisma,
+ User,
+ UserInvoice,
+ UserStripeCustomer,
+ UserSubscription,
+} from '@prisma/client';
+import Stripe from 'stripe';
+
+import { Config } from '../../config';
+import { PrismaService } from '../../prisma';
+
+const OnEvent = (
+ event: Stripe.Event.Type,
+ opts?: Parameters[1]
+) => RawOnEvent(event, opts);
+
+// also used as lookup key for stripe prices
+export enum SubscriptionRecurring {
+ Monthly = 'monthly',
+ Yearly = 'yearly',
+}
+
+export enum SubscriptionPlan {
+ Free = 'free',
+ Pro = 'pro',
+ Team = 'team',
+ Enterprise = 'enterprise',
+}
+
+// see https://stripe.com/docs/api/subscriptions/object#subscription_object-status
+export enum SubscriptionStatus {
+ Active = 'active',
+ PastDue = 'past_due',
+ Unpaid = 'unpaid',
+ Canceled = 'canceled',
+ Incomplete = 'incomplete',
+ Paused = 'paused',
+ IncompleteExpired = 'incomplete_expired',
+ Trialing = 'trialing',
+}
+
+export enum InvoiceStatus {
+ Draft = 'draft',
+ Open = 'open',
+ Void = 'void',
+ Paid = 'paid',
+ Uncollectible = 'uncollectible',
+}
+
+@Injectable()
+export class SubscriptionService {
+ private readonly paymentConfig: Config['payment'];
+ private readonly logger = new Logger(SubscriptionService.name);
+
+ constructor(
+ config: Config,
+ private readonly stripe: Stripe,
+ private readonly db: PrismaService
+ ) {
+ this.paymentConfig = config.payment;
+
+ if (
+ !this.paymentConfig.stripe.keys.APIKey ||
+ !this.paymentConfig.stripe.keys.webhookKey /* default empty string */
+ ) {
+ this.logger.warn('Stripe API key not set, Stripe will be disabled');
+ this.logger.warn('Set STRIPE_API_KEY to enable Stripe');
+ }
+ }
+
+ async listPrices() {
+ return this.stripe.prices.list({
+ lookup_keys: Object.values(SubscriptionRecurring),
+ });
+ }
+
+ async createCheckoutSession({
+ user,
+ recurring,
+ redirectUrl,
+ }: {
+ user: User;
+ recurring: SubscriptionRecurring;
+ redirectUrl: string;
+ }) {
+ const currentSubscription = await this.db.userSubscription.findUnique({
+ where: {
+ userId: user.id,
+ },
+ });
+
+ if (currentSubscription && currentSubscription.end < new Date()) {
+ throw new Error('User already has a subscription');
+ }
+
+ const prices = await this.stripe.prices.list({
+ lookup_keys: [recurring],
+ });
+
+ if (!prices.data.length) {
+ throw new Error(`Unknown subscription recurring: ${recurring}`);
+ }
+
+ const customer = await this.getOrCreateCustomer(user);
+ return await this.stripe.checkout.sessions.create({
+ line_items: [
+ {
+ price: prices.data[0].id,
+ quantity: 1,
+ },
+ ],
+ allow_promotion_codes: true,
+ tax_id_collection: {
+ enabled: true,
+ },
+ mode: 'subscription',
+ success_url: redirectUrl,
+ customer: customer.stripeCustomerId,
+ customer_update: {
+ address: 'auto',
+ name: 'auto',
+ },
+ });
+ }
+
+ async cancelSubscription(userId: string): Promise {
+ const user = await this.db.user.findUnique({
+ where: {
+ id: userId,
+ },
+ include: {
+ subscription: true,
+ },
+ });
+
+ if (!user?.subscription) {
+ throw new Error('User has no subscription');
+ }
+
+ if (user.subscription.canceledAt) {
+ throw new Error('User subscription has already been canceled ');
+ }
+
+ // should release the schedule first
+ if (user.subscription.stripeScheduleId) {
+ await this.stripe.subscriptionSchedules.release(
+ user.subscription.stripeScheduleId
+ );
+ }
+
+ // let customer contact support if they want to cancel immediately
+ // see https://stripe.com/docs/billing/subscriptions/cancel
+ const subscription = await this.stripe.subscriptions.update(
+ user.subscription.stripeSubscriptionId,
+ {
+ cancel_at_period_end: true,
+ }
+ );
+
+ return await this.saveSubscription(user, subscription);
+ }
+
+ async resumeCanceledSubscriptin(userId: string): Promise {
+ const user = await this.db.user.findUnique({
+ where: {
+ id: userId,
+ },
+ include: {
+ subscription: true,
+ },
+ });
+
+ if (!user?.subscription) {
+ throw new Error('User has no subscription');
+ }
+
+ if (!user.subscription.canceledAt) {
+ throw new Error('User subscription is not canceled');
+ }
+
+ if (user.subscription.end < new Date()) {
+ throw new Error(
+ 'User subscription has already expired, please checkout again.'
+ );
+ }
+
+ const subscription = await this.stripe.subscriptions.update(
+ user.subscription.stripeSubscriptionId,
+ {
+ cancel_at_period_end: false,
+ }
+ );
+
+ return await this.saveSubscription(user, subscription);
+ }
+
+ async updateSubscriptionRecurring(
+ userId: string,
+ recurring: string
+ ): Promise {
+ const user = await this.db.user.findUnique({
+ where: {
+ id: userId,
+ },
+ include: {
+ subscription: true,
+ },
+ });
+
+ if (!user?.subscription) {
+ throw new Error('User has no subscription');
+ }
+
+ if (user.subscription.recurring === recurring) {
+ throw new Error('User has already subscribed to this plan');
+ }
+
+ const prices = await this.stripe.prices.list({
+ lookup_keys: [recurring],
+ });
+
+ if (!prices.data.length) {
+ throw new Error(`Unknown subscription recurring: ${recurring}`);
+ }
+
+ const newPrice = prices.data[0];
+
+ // a schedule existing
+ if (user.subscription.stripeScheduleId) {
+ const schedule = await this.stripe.subscriptionSchedules.retrieve(
+ user.subscription.stripeScheduleId
+ );
+
+ // a scheduled subscription's old price equals the change
+ if (
+ schedule.phases[0] &&
+ (schedule.phases[0].items[0].price as string) === newPrice.id
+ ) {
+ await this.stripe.subscriptionSchedules.release(
+ user.subscription.stripeScheduleId
+ );
+
+ return await this.db.userSubscription.update({
+ where: {
+ id: user.subscription.id,
+ },
+ data: {
+ recurring,
+ },
+ });
+ } else {
+ throw new Error(
+ 'Unexpected subscription scheduled, please contact the supporters'
+ );
+ }
+ } else {
+ const schedule = await this.stripe.subscriptionSchedules.create({
+ from_subscription: user.subscription.stripeSubscriptionId,
+ });
+
+ await this.stripe.subscriptionSchedules.update(schedule.id, {
+ phases: [
+ {
+ items: [
+ {
+ price: schedule.phases[0].items[0].price as string,
+ quantity: 1,
+ },
+ ],
+ start_date: schedule.phases[0].start_date,
+ end_date: schedule.phases[0].end_date,
+ },
+ {
+ items: [
+ {
+ price: newPrice.id,
+ quantity: 1,
+ },
+ ],
+ },
+ ],
+ });
+
+ return await this.db.userSubscription.update({
+ where: {
+ id: user.subscription.id,
+ },
+ data: {
+ recurring,
+ stripeScheduleId: schedule.id,
+ },
+ });
+ }
+ }
+
+ @OnEvent('customer.subscription.created')
+ @OnEvent('customer.subscription.updated')
+ async onSubscriptionChanges(subscription: Stripe.Subscription) {
+ const user = await this.retrieveUserFromCustomer(
+ subscription.customer as string
+ );
+
+ await this.saveSubscription(user, subscription);
+ }
+
+ @OnEvent('customer.subscription.deleted')
+ async onSubscriptionDeleted(subscription: Stripe.Subscription) {
+ const user = await this.retrieveUserFromCustomer(
+ subscription.customer as string
+ );
+
+ await this.db.userSubscription.deleteMany({
+ where: {
+ stripeSubscriptionId: subscription.id,
+ userId: user.id,
+ },
+ });
+ }
+
+ @OnEvent('invoice.created')
+ async onInvoiceCreated(invoice: Stripe.Invoice) {
+ await this.saveInvoice(invoice);
+ }
+
+ @OnEvent('invoice.paid')
+ async onInvoicePaid(invoice: Stripe.Invoice) {
+ await this.saveInvoice(invoice);
+ }
+
+ @OnEvent('invoice.finalization_failed')
+ async onInvoiceFinalizeFailed(invoice: Stripe.Invoice) {
+ await this.saveInvoice(invoice);
+ }
+
+ @OnEvent('invoice.payment_failed')
+ async onInvoicePaymentFailed(invoice: Stripe.Invoice) {
+ await this.saveInvoice(invoice);
+ }
+
+ private async saveSubscription(
+ user: User,
+ subscription: Stripe.Subscription
+ ): Promise {
+ // get next bill date from upcoming invoice
+ // see https://stripe.com/docs/api/invoices/upcoming
+ let nextBillAt: Date | null = null;
+ if (
+ (subscription.status === SubscriptionStatus.Active ||
+ subscription.status === SubscriptionStatus.Trialing) &&
+ !subscription.canceled_at
+ ) {
+ try {
+ const nextInvoice = await this.stripe.invoices.retrieveUpcoming({
+ customer: subscription.customer as string,
+ subscription: subscription.id,
+ });
+
+ nextBillAt = new Date(nextInvoice.created * 1000);
+ } catch (e) {
+ // no upcoming invoice
+ // safe to ignore
+ }
+ }
+
+ const price = subscription.items.data[0].price;
+
+ const commonData = {
+ start: new Date(subscription.current_period_start * 1000),
+ end: new Date(subscription.current_period_end * 1000),
+ trialStart: subscription.trial_start
+ ? new Date(subscription.trial_start * 1000)
+ : null,
+ trialEnd: subscription.trial_end
+ ? new Date(subscription.trial_end * 1000)
+ : null,
+ nextBillAt,
+ canceledAt: subscription.canceled_at
+ ? new Date(subscription.canceled_at * 1000)
+ : null,
+ stripeSubscriptionId: subscription.id,
+ recurring: price.lookup_key ?? price.id,
+ // TODO: dynamic plans
+ plan: SubscriptionPlan.Pro,
+ status: subscription.status,
+ stripeScheduleId: subscription.schedule as string | null,
+ };
+
+ const currentSubscription = await this.db.userSubscription.findUnique({
+ where: {
+ userId: user.id,
+ },
+ });
+
+ if (currentSubscription) {
+ const update: Prisma.UserSubscriptionUpdateInput = {
+ ...commonData,
+ };
+
+ // a schedule exists, update the recurring to scheduled one
+ if (update.stripeScheduleId) {
+ delete update.recurring;
+ }
+
+ return await this.db.userSubscription.update({
+ where: {
+ id: currentSubscription.id,
+ },
+ data: update,
+ });
+ } else {
+ return await this.db.userSubscription.create({
+ data: {
+ userId: user.id,
+ ...commonData,
+ },
+ });
+ }
+ }
+
+ private async getOrCreateCustomer(user: User): Promise {
+ const customer = await this.db.userStripeCustomer.findUnique({
+ where: {
+ userId: user.id,
+ },
+ });
+
+ if (customer) {
+ return customer;
+ }
+
+ const stripeCustomersList = await this.stripe.customers.list({
+ email: user.email,
+ limit: 1,
+ });
+
+ let stripeCustomer: Stripe.Customer | undefined;
+ if (stripeCustomersList.data.length) {
+ stripeCustomer = stripeCustomersList.data[0];
+ } else {
+ stripeCustomer = await this.stripe.customers.create({
+ email: user.email,
+ });
+ }
+
+ return await this.db.userStripeCustomer.create({
+ data: {
+ userId: user.id,
+ stripeCustomerId: stripeCustomer.id,
+ },
+ });
+ }
+
+ private async retrieveUserFromCustomer(customerId: string) {
+ const customer = await this.db.userStripeCustomer.findUnique({
+ where: {
+ stripeCustomerId: customerId,
+ },
+ include: {
+ user: true,
+ },
+ });
+
+ if (customer?.user) {
+ return customer.user;
+ }
+
+ // customer may not saved is db, check it with stripe
+ const stripeCustomer = await this.stripe.customers.retrieve(customerId);
+
+ if (stripeCustomer.deleted) {
+ throw new Error('Unexpected subscription created with deleted customer');
+ }
+
+ if (!stripeCustomer.email) {
+ throw new Error('Unexpected subscription created with no email customer');
+ }
+
+ const user = await this.db.user.findUnique({
+ where: {
+ email: stripeCustomer.email,
+ },
+ });
+
+ if (!user) {
+ throw new Error(
+ `Unexpected subscription created with unknown customer ${stripeCustomer.email}`
+ );
+ }
+
+ await this.db.userStripeCustomer.create({
+ data: {
+ userId: user.id,
+ stripeCustomerId: stripeCustomer.id,
+ },
+ });
+
+ return user;
+ }
+
+ private async saveInvoice(stripeInvoice: Stripe.Invoice) {
+ if (!stripeInvoice.customer) {
+ throw new Error('Unexpected invoice with no customer');
+ }
+
+ const user = await this.retrieveUserFromCustomer(
+ stripeInvoice.customer as string
+ );
+
+ const invoice = await this.db.userInvoice.findUnique({
+ where: {
+ stripeInvoiceId: stripeInvoice.id,
+ },
+ });
+
+ const data: Partial = {
+ currency: stripeInvoice.currency,
+ amount: stripeInvoice.total,
+ status: stripeInvoice.status ?? InvoiceStatus.Void,
+ };
+
+ // handle payment error
+ if (stripeInvoice.attempt_count > 1) {
+ const paymentIntent = await this.stripe.paymentIntents.retrieve(
+ stripeInvoice.payment_intent as string
+ );
+
+ if (paymentIntent.last_payment_error) {
+ if (paymentIntent.last_payment_error.type === 'card_error') {
+ data.lastPaymentError =
+ paymentIntent.last_payment_error.message ?? 'Failed to pay';
+ } else {
+ data.lastPaymentError = 'Internal Payment error';
+ }
+ }
+ } else if (stripeInvoice.last_finalization_error) {
+ if (stripeInvoice.last_finalization_error.type === 'card_error') {
+ data.lastPaymentError =
+ stripeInvoice.last_finalization_error.message ??
+ 'Failed to finalize invoice';
+ } else {
+ data.lastPaymentError = 'Internal Payment error';
+ }
+ }
+
+ // update invoice
+ if (invoice) {
+ await this.db.userInvoice.update({
+ where: {
+ stripeInvoiceId: stripeInvoice.id,
+ },
+ data,
+ });
+ } else {
+ // create invoice
+ const price = stripeInvoice.lines.data[0].price;
+
+ if (!price || price.type !== 'recurring') {
+ throw new Error('Unexpected invoice with no recurring price');
+ }
+
+ await this.db.userInvoice.create({
+ data: {
+ userId: user.id,
+ stripeInvoiceId: stripeInvoice.id,
+ plan: SubscriptionPlan.Pro,
+ recurring: price.lookup_key ?? price.id,
+ reason: stripeInvoice.billing_reason ?? 'contact support',
+ ...(data as any),
+ },
+ });
+ }
+ }
+}
diff --git a/packages/backend/server/src/modules/payment/stripe.ts b/packages/backend/server/src/modules/payment/stripe.ts
new file mode 100644
index 0000000000..4538471121
--- /dev/null
+++ b/packages/backend/server/src/modules/payment/stripe.ts
@@ -0,0 +1,18 @@
+import { FactoryProvider } from '@nestjs/common';
+import { omit } from 'lodash-es';
+import Stripe from 'stripe';
+
+import { Config } from '../../config';
+
+export const StripeProvider: FactoryProvider = {
+ provide: Stripe,
+ useFactory: (config: Config) => {
+ const stripeConfig = config.payment.stripe;
+
+ return new Stripe(
+ stripeConfig.keys.APIKey,
+ omit(config.payment.stripe, 'keys', 'prices')
+ );
+ },
+ inject: [Config],
+};
diff --git a/packages/backend/server/src/modules/payment/webhook.ts b/packages/backend/server/src/modules/payment/webhook.ts
new file mode 100644
index 0000000000..785ea97832
--- /dev/null
+++ b/packages/backend/server/src/modules/payment/webhook.ts
@@ -0,0 +1,75 @@
+import type { RawBodyRequest } from '@nestjs/common';
+import {
+ Controller,
+ Get,
+ Logger,
+ NotAcceptableException,
+ Post,
+ Req,
+} from '@nestjs/common';
+import { EventEmitter2 } from '@nestjs/event-emitter';
+import type { User } from '@prisma/client';
+import type { Request } from 'express';
+import Stripe from 'stripe';
+
+import { Config } from '../../config';
+import { PrismaService } from '../../prisma';
+import { Auth, CurrentUser } from '../auth';
+
+@Controller('/api/stripe')
+export class StripeWebhook {
+ private readonly config: Config['payment'];
+ private readonly logger = new Logger(StripeWebhook.name);
+
+ constructor(
+ config: Config,
+ private readonly stripe: Stripe,
+ private readonly event: EventEmitter2,
+ private readonly db: PrismaService
+ ) {
+ this.config = config.payment;
+ }
+
+ // just for test
+ @Auth()
+ @Get('/success')
+ async handleSuccess(@CurrentUser() user: User) {
+ return this.db.userSubscription.findUnique({
+ where: {
+ userId: user.id,
+ },
+ });
+ }
+
+ @Post('/webhook')
+ async handleWebhook(@Req() req: RawBodyRequest) {
+ // Check if webhook signing is configured.
+ if (!this.config.stripe.keys.webhookKey) {
+ this.logger.error(
+ 'Stripe Webhook key is not set, but a webhook was received.'
+ );
+ throw new NotAcceptableException();
+ }
+
+ // Retrieve the event by verifying the signature using the raw body and secret.
+ const signature = req.headers['stripe-signature'];
+ try {
+ const event = this.stripe.webhooks.constructEvent(
+ req.rawBody ?? '',
+ signature ?? '',
+ this.config.stripe.keys.webhookKey
+ );
+
+ this.logger.debug(
+ `[${event.id}] Stripe Webhook {${event.type}} received.`
+ );
+
+ // handle duplicated events?
+ // see https://stripe.com/docs/webhooks#handle-duplicate-events
+ await this.event.emitAsync(event.type, event.data.object);
+ } catch (err) {
+ this.logger.error('Stripe Webhook error', err);
+ throw new NotAcceptableException();
+ }
+ }
+}
diff --git a/packages/backend/server/src/modules/users/resolver.ts b/packages/backend/server/src/modules/users/resolver.ts
index b735a4c1f1..cc3a529f44 100644
--- a/packages/backend/server/src/modules/users/resolver.ts
+++ b/packages/backend/server/src/modules/users/resolver.ts
@@ -21,7 +21,7 @@ import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { PrismaService } from '../../prisma/service';
import { CloudThrottlerGuard, Throttle } from '../../throttler';
import type { FileUpload } from '../../types';
-import { Auth, CurrentUser, Public } from '../auth/guard';
+import { Auth, CurrentUser, Public, Publicable } from '../auth/guard';
import { StorageService } from '../storage/storage.service';
import { NewFeaturesKind } from './types';
import { UsersService } from './users';
@@ -97,11 +97,17 @@ export class UserResolver {
ttl: 60,
},
})
+ @Publicable()
@Query(() => UserType, {
name: 'currentUser',
description: 'Get current user',
+ nullable: true,
})
- async currentUser(@CurrentUser() user: UserType) {
+ async currentUser(@CurrentUser() user?: UserType) {
+ if (!user) {
+ return null;
+ }
+
const storedUser = await this.users.findUserById(user.id);
if (!storedUser) {
throw new BadRequestException(`User ${user.id} not found in db`);
diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql
index 63d8248ede..6be3ba621e 100644
--- a/packages/backend/server/src/schema.gql
+++ b/packages/backend/server/src/schema.gql
@@ -23,6 +23,8 @@ type UserType {
"""User password has been set"""
hasPassword: Boolean
token: TokenType!
+ subscription: UserSubscription
+ invoices(take: Int = 8, skip: Int): [UserInvoice!]!
}
"""
@@ -55,6 +57,73 @@ type TokenType {
sessionToken: String
}
+type SubscriptionPrice {
+ type: String!
+ plan: SubscriptionPlan!
+ currency: String!
+ amount: Int!
+ yearlyAmount: Int!
+}
+
+enum SubscriptionPlan {
+ Free
+ Pro
+ Team
+ Enterprise
+}
+
+type UserSubscription {
+ id: String!
+ plan: SubscriptionPlan!
+ recurring: SubscriptionRecurring!
+ status: SubscriptionStatus!
+ start: DateTime!
+ end: DateTime!
+ trialStart: DateTime
+ trialEnd: DateTime
+ nextBillAt: DateTime
+ canceledAt: DateTime
+ createdAt: DateTime!
+ updatedAt: DateTime!
+}
+
+enum SubscriptionRecurring {
+ Monthly
+ Yearly
+}
+
+enum SubscriptionStatus {
+ Active
+ PastDue
+ Unpaid
+ Canceled
+ Incomplete
+ Paused
+ IncompleteExpired
+ Trialing
+}
+
+type UserInvoice {
+ id: String!
+ plan: SubscriptionPlan!
+ recurring: SubscriptionRecurring!
+ currency: String!
+ amount: Int!
+ status: InvoiceStatus!
+ reason: String!
+ lastPaymentError: String
+ createdAt: DateTime!
+ updatedAt: DateTime!
+}
+
+enum InvoiceStatus {
+ Draft
+ Open
+ Void
+ Paid
+ Uncollectible
+}
+
type InviteUserType {
"""User name"""
name: String
@@ -166,10 +235,11 @@ type Query {
checkBlobSize(workspaceId: String!, size: Float!): WorkspaceBlobSizes!
"""Get current user"""
- currentUser: UserType!
+ currentUser: UserType
"""Get user by email"""
user(email: String!): UserType
+ prices: [SubscriptionPrice!]!
}
type Mutation {
@@ -205,6 +275,12 @@ type Mutation {
removeAvatar: RemoveAvatar!
deleteAccount: DeleteAccount!
addToNewFeaturesWaitingList(type: NewFeaturesKind!, email: String!): AddToNewFeaturesWaitingList!
+
+ """Create a subscription checkout link of stripe"""
+ checkout(recurring: SubscriptionRecurring!): String!
+ cancelSubscription: UserSubscription!
+ resumeSubscription: UserSubscription!
+ updateSubscriptionRecurring(recurring: SubscriptionRecurring!): UserSubscription!
}
"""The `Upload` scalar type represents a file upload."""
diff --git a/packages/backend/server/tests/user.e2e.ts b/packages/backend/server/tests/user.e2e.ts
index da06bc4322..065925af21 100644
--- a/packages/backend/server/tests/user.e2e.ts
+++ b/packages/backend/server/tests/user.e2e.ts
@@ -67,6 +67,6 @@ test('should be able to delete user', async t => {
`,
})
.expect(200);
- await t.throwsAsync(() => currentUser(app, user.token.token));
+ t.is(await currentUser(app, user.token.token), null);
t.pass();
});
diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts
index 1a47becf06..3a84e42fe4 100644
--- a/packages/frontend/graphql/src/schema.ts
+++ b/packages/frontend/graphql/src/schema.ts
@@ -32,6 +32,14 @@ export interface Scalars {
Upload: { input: File; output: File };
}
+export enum InvoiceStatus {
+ Draft = 'Draft',
+ Open = 'Open',
+ Paid = 'Paid',
+ Uncollectible = 'Uncollectible',
+ Void = 'Void',
+}
+
export enum NewFeaturesKind {
EarlyAccess = 'EarlyAccess',
}
@@ -44,6 +52,29 @@ export enum Permission {
Write = 'Write',
}
+export enum SubscriptionPlan {
+ Enterprise = 'Enterprise',
+ Free = 'Free',
+ Pro = 'Pro',
+ Team = 'Team',
+}
+
+export enum SubscriptionRecurring {
+ Monthly = 'Monthly',
+ Yearly = 'Yearly',
+}
+
+export enum SubscriptionStatus {
+ Active = 'Active',
+ Canceled = 'Canceled',
+ Incomplete = 'Incomplete',
+ IncompleteExpired = 'IncompleteExpired',
+ PastDue = 'PastDue',
+ Paused = 'Paused',
+ Trialing = 'Trialing',
+ Unpaid = 'Unpaid',
+}
+
export interface UpdateWorkspaceInput {
id: Scalars['ID']['input'];
/** is Public workspace */
@@ -173,7 +204,7 @@ export type GetCurrentUserQuery = {
avatarUrl: string | null;
createdAt: string | null;
token: { __typename?: 'TokenType'; sessionToken: string | null };
- };
+ } | null;
};
export type GetInviteInfoQueryVariables = Exact<{
diff --git a/yarn.lock b/yarn.lock
index 9445b44dcf..40dfb5747c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -671,6 +671,7 @@ __metadata:
"@nestjs/apollo": "npm:^12.0.9"
"@nestjs/common": "npm:^10.2.7"
"@nestjs/core": "npm:^10.2.7"
+ "@nestjs/event-emitter": "npm:^2.0.2"
"@nestjs/graphql": "npm:^12.0.9"
"@nestjs/platform-express": "npm:^10.2.7"
"@nestjs/platform-socket.io": "npm:^10.2.7"
@@ -735,6 +736,7 @@ __metadata:
semver: "npm:^7.5.4"
sinon: "npm:^16.1.0"
socket.io: "npm:^4.7.2"
+ stripe: "npm:^13.6.0"
supertest: "npm:^6.3.3"
ts-node: "npm:^10.9.1"
typescript: "npm:^5.2.2"
@@ -7280,6 +7282,19 @@ __metadata:
languageName: node
linkType: hard
+"@nestjs/event-emitter@npm:^2.0.2":
+ version: 2.0.2
+ resolution: "@nestjs/event-emitter@npm:2.0.2"
+ dependencies:
+ eventemitter2: "npm:6.4.9"
+ peerDependencies:
+ "@nestjs/common": ^8.0.0 || ^9.0.0 || ^10.0.0
+ "@nestjs/core": ^8.0.0 || ^9.0.0 || ^10.0.0
+ reflect-metadata: ^0.1.12
+ checksum: 9c7d2645b14bef5a9d26a8fbafb5963e18c9c15e267980c55abd913c8af9215ae363b8c0fc78711c22126e0a973f80aec8b8e962a64e699f523128d11c033894
+ languageName: node
+ linkType: hard
+
"@nestjs/graphql@npm:^12.0.9":
version: 12.0.9
resolution: "@nestjs/graphql@npm:12.0.9"
@@ -13432,6 +13447,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/node@npm:>=8.1.0":
+ version: 20.6.2
+ resolution: "@types/node@npm:20.6.2"
+ checksum: 4b150698cf90c211d4f2f021618f06c33a337d74e9a0ce10ec2e7123f02aacc231eff62118101f56de75f7be309c2da6eb0edb8388d501d4195c50bb919c7a05
+ languageName: node
+ linkType: hard
+
"@types/node@npm:^16.0.0":
version: 16.18.58
resolution: "@types/node@npm:16.18.58"
@@ -20137,6 +20159,13 @@ __metadata:
languageName: node
linkType: hard
+"eventemitter2@npm:6.4.9":
+ version: 6.4.9
+ resolution: "eventemitter2@npm:6.4.9"
+ checksum: b829b1c6b11e15926b635092b5ad62b4463d1c928859831dcae606e988cf41893059e3541f5a8209d21d2f15314422ddd4d84d20830b4bf44978608d15b06b08
+ languageName: node
+ linkType: hard
+
"eventemitter3@npm:^3.1.0":
version: 3.1.2
resolution: "eventemitter3@npm:3.1.2"
@@ -31941,6 +31970,16 @@ __metadata:
languageName: node
linkType: hard
+"stripe@npm:^13.6.0":
+ version: 13.6.0
+ resolution: "stripe@npm:13.6.0"
+ dependencies:
+ "@types/node": "npm:>=8.1.0"
+ qs: "npm:^6.11.0"
+ checksum: 3fae1ed3dc845166c36fb28e4297ec770bb1f1b35e88b0166c465a31d41216203341b1055bf63b653fa3c66cd5d2eb72fdfaec9b58a7d467d207645a12b2cde0
+ languageName: node
+ linkType: hard
+
"strnum@npm:^1.0.5":
version: 1.0.5
resolution: "strnum@npm:1.0.5"
From 1d62133f4ffab448c7ac0d16eaa4188959dd2ad6 Mon Sep 17 00:00:00 2001
From: forehalo
Date: Thu, 19 Oct 2023 10:08:16 +0800
Subject: [PATCH 02/27] feat(core): impl subscription plans setting
---
.../setting-modal/general-setting/index.tsx | 23 +-
.../general-setting/plans/index.tsx | 417 ++++++++++++++++++
.../general-setting/plans/style.css.ts | 104 +++++
.../src/graphql/cancel-subscription.gql | 8 +
.../src/graphql/create-checkout-link.gql | 3 +
.../frontend/graphql/src/graphql/index.ts | 105 +++++
.../frontend/graphql/src/graphql/invoices.gql | 15 +
.../frontend/graphql/src/graphql/prices.gql | 9 +
.../graphql/src/graphql/subscription.gql | 14 +
.../graphql/update-subscription-billing.gql | 8 +
packages/frontend/graphql/src/schema.ts | 124 ++++++
11 files changed, 829 insertions(+), 1 deletion(-)
create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx
create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts
create mode 100644 packages/frontend/graphql/src/graphql/cancel-subscription.gql
create mode 100644 packages/frontend/graphql/src/graphql/create-checkout-link.gql
create mode 100644 packages/frontend/graphql/src/graphql/invoices.gql
create mode 100644 packages/frontend/graphql/src/graphql/prices.gql
create mode 100644 packages/frontend/graphql/src/graphql/subscription.gql
create mode 100644 packages/frontend/graphql/src/graphql/update-subscription-billing.gql
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx
index a57f442f1d..9a1c329fd4 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx
@@ -9,6 +9,7 @@ import type { ReactElement, SVGProps } from 'react';
import { AboutAffine } from './about';
import { AppearanceSettings } from './appearance';
+import { AFFiNECloudPlans } from './plans';
import { Plugins } from './plugins';
import { Shortcuts } from './shortcuts';
@@ -16,7 +17,9 @@ export type GeneralSettingKeys =
| 'shortcuts'
| 'appearance'
| 'plugins'
- | 'about';
+ | 'about'
+ | 'plans'
+ | 'billing';
interface GeneralSettingListItem {
key: GeneralSettingKeys;
@@ -43,6 +46,22 @@ export const useGeneralSettingList = (): GeneralSettingList => {
icon: KeyboardIcon,
testId: 'shortcuts-panel-trigger',
},
+ {
+ key: 'plans',
+ // TODO: i18n
+ title: 'AFFiNE Cloud Plans',
+ // TODO: icon
+ icon: KeyboardIcon,
+ testId: 'plans-panel-trigger',
+ },
+ {
+ key: 'billing',
+ // TODO: i18n
+ title: 'Billing',
+ // TODO: icon
+ icon: KeyboardIcon,
+ testId: 'billing-panel-trigger',
+ },
{
key: 'plugins',
title: 'Plugins',
@@ -72,6 +91,8 @@ export const GeneralSetting = ({ generalKey }: GeneralSettingProps) => {
return ;
case 'about':
return ;
+ case 'plans':
+ return ;
default:
return null;
}
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx
new file mode 100644
index 0000000000..1c79de89f2
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx
@@ -0,0 +1,417 @@
+import { RadioButton, RadioButtonGroup } from '@affine/component';
+import { SettingHeader } from '@affine/component/setting-components';
+import {
+ cancelSubscriptionMutation,
+ checkoutMutation,
+ pricesQuery,
+ SubscriptionPlan,
+ subscriptionQuery,
+ SubscriptionRecurring,
+ updateSubscriptionMutation,
+} from '@affine/graphql';
+import { useMutation, useQuery } from '@affine/workspace/affine/gql';
+import { Button } from '@toeverything/components/button';
+import {
+ type PropsWithChildren,
+ Suspense,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+
+import * as styles from './style.css';
+
+interface FixedPrice {
+ type: 'fixed';
+ plan: SubscriptionPlan;
+ price: string;
+ yearlyPrice: string;
+ discount?: string;
+ benefits: string[];
+}
+
+interface DynamicPrice {
+ type: 'dynamic';
+ plan: SubscriptionPlan;
+ contact: boolean;
+ benefits: string[];
+}
+
+// TODO: i18n all things
+const planDetail = new Map([
+ [
+ SubscriptionPlan.Free,
+ {
+ type: 'fixed',
+ plan: SubscriptionPlan.Free,
+ price: '0',
+ yearlyPrice: '0',
+ benefits: [
+ 'Unlimited local workspace',
+ 'Unlimited login devices',
+ 'Unlimited blocks',
+ 'AFFiNE Cloud Storage 10GB',
+ 'The maximum file size is 10M',
+ 'Number of members per Workspace ≤ 3',
+ ],
+ },
+ ],
+ [
+ SubscriptionPlan.Pro,
+ {
+ type: 'fixed',
+ plan: SubscriptionPlan.Pro,
+ price: '1',
+ yearlyPrice: '1',
+ benefits: [
+ 'Unlimited local workspace',
+ 'Unlimited login devices',
+ 'Unlimited blocks',
+ 'AFFiNE Cloud Storage 100GB',
+ 'The maximum file size is 500M',
+ 'Number of members per Workspace ≤ 10',
+ ],
+ },
+ ],
+ [
+ SubscriptionPlan.Team,
+ {
+ type: 'dynamic',
+ plan: SubscriptionPlan.Team,
+ contact: true,
+ benefits: [
+ 'Best team workspace for collaboration and knowledge distilling.',
+ 'Focusing on what really matters with team project management and automation.',
+ 'Pay for seats, fits all team size.',
+ ],
+ },
+ ],
+ [
+ SubscriptionPlan.Enterprise,
+ {
+ type: 'dynamic',
+ plan: SubscriptionPlan.Enterprise,
+ contact: true,
+ benefits: [
+ 'Solutions & best practices for dedicated needs.',
+ 'Embedable & interrogations with IT support.',
+ ],
+ },
+ ],
+]);
+
+const Settings = () => {
+ const { data, mutate } = useQuery({
+ query: subscriptionQuery,
+ });
+
+ const {
+ data: { prices },
+ } = useQuery({
+ query: pricesQuery,
+ });
+
+ prices.forEach(price => {
+ const detail = planDetail.get(price.plan);
+
+ if (detail?.type === 'fixed') {
+ detail.price = (price.amount / 100).toFixed(2);
+ detail.yearlyPrice = (price.yearlyAmount / 100 / 12).toFixed(2);
+ detail.discount = (
+ (1 - price.yearlyAmount / 12 / price.amount) *
+ 100
+ ).toFixed(2);
+ }
+ });
+
+ const loggedIn = !!data.currentUser;
+ const subscription = data.currentUser?.subscription;
+
+ const [recurring, setRecurring] = useState(
+ subscription?.recurring ?? SubscriptionRecurring.Monthly
+ );
+
+ const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
+ const currentRecurring =
+ subscription?.recurring ?? SubscriptionRecurring.Monthly;
+
+ const refresh = useCallback(() => {
+ mutate();
+ }, [mutate]);
+
+ const yearlyDiscount = (
+ planDetail.get(SubscriptionPlan.Pro) as FixedPrice | undefined
+ )?.discount;
+
+ return (
+ <>
+
+ You are current on the {currentPlan} plan. If you have any
+ questions, please contact our{' '}
+ {/*TODO: add action*/}customer support .
+
+ }
+ />
+
+
+ {Object.values(SubscriptionRecurring).map(plan => (
+
+ {plan}
+ {plan === SubscriptionRecurring.Yearly && yearlyDiscount && (
+
+ {yearlyDiscount}% off
+
+ )}
+
+ ))}
+
+ {/* TODO: plan cards horizontal scroll behavior is not the same as design */}
+ {/* TODO: may scroll current plan into view when first loading? */}
+
+ {Array.from(planDetail.values()).map(detail => {
+ const isCurrent =
+ currentPlan === detail.plan && currentRecurring === recurring;
+ return (
+
+
+
+ {detail.plan}{' '}
+ {'discount' in detail && (
+
+ {detail.discount}% off
+
+ )}
+
+
+
+ $
+ {detail.type === 'dynamic'
+ ? '?'
+ : recurring === SubscriptionRecurring.Monthly
+ ? detail.price
+ : detail.yearlyPrice}
+
+ per month
+
+ {
+ // branches:
+ // if contact => 'Contact Sales'
+ // if not signed in:
+ // if free => 'Sign up free'
+ // else => 'Buy Pro'
+ // else
+ // if isCurrent => 'Current Plan'
+ // else if free => 'Downgrade'
+ // else if currentRecurring !== recurring => 'Change to {recurring} Billing'
+ // else => 'Upgrade'
+ // TODO: should replace with components with proper actions
+ detail.type === 'dynamic' ? (
+
+ ) : loggedIn ? (
+ isCurrent ? (
+
+ ) : detail.plan === SubscriptionPlan.Free ? (
+
+ ) : currentRecurring !== recurring ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ {detail.plan === SubscriptionPlan.Free
+ ? 'Sign up free'
+ : 'Buy Pro'}
+
+ )
+ }
+
+
+ {detail.benefits.map((content, i) => (
+
+
+ {/* TODO: icons */}
+ {detail.type == 'dynamic' ? '·' : '✅'}
+
+ {content}
+
+ ))}
+
+
+ );
+ })}
+
+
+ See all plans →{/* TODO: icon */}
+
+
+ >
+ );
+};
+
+const Downgrade = ({ onActionDone }: { onActionDone: () => void }) => {
+ const { isMutating, trigger } = useMutation({
+ mutation: cancelSubscriptionMutation,
+ });
+
+ const downgrade = useCallback(() => {
+ trigger(null, { onSuccess: onActionDone });
+ }, [trigger, onActionDone]);
+
+ return (
+
+ Downgrade
+
+ );
+};
+
+const Upgrade = ({
+ recurring,
+ onActionDone,
+}: {
+ recurring: SubscriptionRecurring;
+ onActionDone: () => void;
+}) => {
+ const { isMutating, trigger, data } = useMutation({
+ mutation: checkoutMutation,
+ });
+
+ const upgrade = useCallback(() => {
+ trigger({ recurring });
+ }, [trigger, recurring]);
+
+ const newTabRef = useRef(null);
+
+ useEffect(() => {
+ if (data?.checkout) {
+ if (newTabRef.current) {
+ newTabRef.current.focus();
+ } else {
+ // FIXME: safari prevents from opening new tab by window api
+ // TODO(@xp): what if electron?
+ const newTab = window.open(
+ data.checkout,
+ '_blank',
+ 'noopener noreferrer'
+ );
+
+ if (newTab) {
+ newTabRef.current = newTab;
+ const update = () => {
+ onActionDone();
+ };
+ newTab.addEventListener('close', update);
+
+ return () => newTab.removeEventListener('close', update);
+ }
+ }
+ }
+
+ return;
+ }, [data?.checkout, onActionDone]);
+
+ return (
+
+ Upgrade
+
+ );
+};
+
+const ChangeRecurring = ({
+ from: _from /* TODO: from can be useful when showing confirmation modal */,
+ to,
+ onActionDone,
+}: {
+ from: SubscriptionRecurring;
+ to: SubscriptionRecurring;
+ onActionDone: () => void;
+}) => {
+ const { isMutating, trigger } = useMutation({
+ mutation: updateSubscriptionMutation,
+ });
+
+ const change = useCallback(() => {
+ trigger({ recurring: to }, { onSuccess: onActionDone });
+ }, [trigger, onActionDone, to]);
+
+ return (
+
+ Change to {to} Billing
+
+ );
+};
+
+const ContactSales = () => {
+ return (
+ // TODO: add action
+
+ Contact Sales
+
+ );
+};
+
+const CurrentPlan = () => {
+ return Current Plan ;
+};
+
+const SignupAction = ({ children }: PropsWithChildren) => {
+ // TODO: add login action
+ return (
+
+ {children}
+
+ );
+};
+
+export const AFFiNECloudPlans = () => {
+ return (
+ // TODO: loading skeleton
+ // TODO: Error Boundary
+
+
+
+ );
+};
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts
new file mode 100644
index 0000000000..d12bb790eb
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts
@@ -0,0 +1,104 @@
+import { style } from '@vanilla-extract/css';
+
+export const wrapper = style({
+ width: '100%',
+});
+export const recurringRadioGroup = style({
+ width: '256px',
+});
+
+export const radioButtonDiscount = style({
+ marginLeft: '4px',
+ color: 'var(--affine-primary-color)',
+});
+
+export const planCardsWrapper = style({
+ marginTop: '24px',
+ display: 'flex',
+ overflowX: 'auto',
+});
+
+export const planCard = style({
+ minHeight: '426px',
+ minWidth: '258px',
+ borderRadius: '16px',
+ padding: '20px',
+ border: '1px solid var(--affine-border-color)',
+
+ selectors: {
+ '&:not(:last-child)': {
+ marginRight: '16px',
+ },
+ },
+});
+
+export const currentPlanCard = style([
+ planCard,
+ {
+ borderWidth: '2px',
+ borderColor: 'var(--affine-primary-color)',
+ },
+]);
+
+export const discountLabel = style({
+ color: 'var(--affine-primary-color)',
+ marginLeft: '8px',
+ lineHeight: '20px',
+ fontSize: 'var(--affine-font-xs)',
+ fontWeight: 500,
+ padding: '0 4px',
+ backgroundColor: 'var(--affine-blue-50)',
+ borderRadius: '4px',
+ display: 'inline-block',
+ height: '100%',
+});
+
+export const planTitle = style({
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ gap: '10px',
+ fontWeight: 600,
+});
+
+export const planPrice = style({
+ fontSize: 'var(--affine-font-h-5)',
+ marginRight: '8px',
+});
+
+export const planPriceDesc = style({
+ color: 'var(--affine-text-secondary-color)',
+ fontSize: 'var(--affine-font-sm)',
+});
+
+export const planAction = style({
+ width: '100%',
+});
+
+export const planBenefits = style({
+ marginTop: '20px',
+ fontSize: 'var(--affine-font-xs)',
+});
+
+export const planBenefit = style({
+ display: 'flex',
+ selectors: {
+ '&:not(:last-child)': {
+ marginBottom: '8px',
+ },
+ },
+});
+
+export const planBenefitIcon = style({
+ display: 'inline-block',
+ marginRight: '8px',
+});
+
+export const allPlansLink = style({
+ display: 'block',
+ marginTop: '36px',
+ color: 'var(--affine-primary-color)',
+ background: 'transparent',
+ borderColor: 'transparent',
+ fontSize: 'var(--affine-font-xs)',
+});
diff --git a/packages/frontend/graphql/src/graphql/cancel-subscription.gql b/packages/frontend/graphql/src/graphql/cancel-subscription.gql
new file mode 100644
index 0000000000..128e206781
--- /dev/null
+++ b/packages/frontend/graphql/src/graphql/cancel-subscription.gql
@@ -0,0 +1,8 @@
+mutation cancelSubscription {
+ cancelSubscription {
+ id
+ status
+ nextBillAt
+ canceledAt
+ }
+}
diff --git a/packages/frontend/graphql/src/graphql/create-checkout-link.gql b/packages/frontend/graphql/src/graphql/create-checkout-link.gql
new file mode 100644
index 0000000000..b4e4704e97
--- /dev/null
+++ b/packages/frontend/graphql/src/graphql/create-checkout-link.gql
@@ -0,0 +1,3 @@
+mutation checkout($recurring: SubscriptionRecurring!) {
+ checkout(recurring: $recurring)
+}
diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts
index f31b3c5c8c..29330c8fed 100644
--- a/packages/frontend/graphql/src/graphql/index.ts
+++ b/packages/frontend/graphql/src/graphql/index.ts
@@ -79,6 +79,22 @@ query allBlobSizes {
}`,
};
+export const cancelSubscriptionMutation = {
+ id: 'cancelSubscriptionMutation' as const,
+ operationName: 'cancelSubscription',
+ definitionName: 'cancelSubscription',
+ containsFile: false,
+ query: `
+mutation cancelSubscription {
+ cancelSubscription {
+ id
+ status
+ nextBillAt
+ canceledAt
+ }
+}`,
+};
+
export const changeEmailMutation = {
id: 'changeEmailMutation' as const,
operationName: 'changeEmail',
@@ -111,6 +127,17 @@ mutation changePassword($token: String!, $newPassword: String!) {
}`,
};
+export const checkoutMutation = {
+ id: 'checkoutMutation' as const,
+ operationName: 'checkout',
+ definitionName: 'checkout',
+ containsFile: false,
+ query: `
+mutation checkout($recurring: SubscriptionRecurring!) {
+ checkout(recurring: $recurring)
+}`,
+};
+
export const createWorkspaceMutation = {
id: 'createWorkspaceMutation' as const,
operationName: 'createWorkspace',
@@ -321,6 +348,29 @@ query getWorkspaces {
}`,
};
+export const invoicesQuery = {
+ id: 'invoicesQuery' as const,
+ operationName: 'invoices',
+ definitionName: 'currentUser',
+ containsFile: false,
+ query: `
+query invoices($take: Int!, $skip: Int!) {
+ currentUser {
+ invoices(take: $take, skip: $skip) {
+ id
+ status
+ plan
+ recurring
+ currency
+ amount
+ reason
+ lastPaymentError
+ createdAt
+ }
+ }
+}`,
+};
+
export const leaveWorkspaceMutation = {
id: 'leaveWorkspaceMutation' as const,
operationName: 'leaveWorkspace',
@@ -336,6 +386,23 @@ mutation leaveWorkspace($workspaceId: String!, $workspaceName: String!, $sendLea
}`,
};
+export const pricesQuery = {
+ id: 'pricesQuery' as const,
+ operationName: 'prices',
+ definitionName: 'prices',
+ containsFile: false,
+ query: `
+query prices {
+ prices {
+ type
+ plan
+ currency
+ amount
+ yearlyAmount
+ }
+}`,
+};
+
export const removeAvatarMutation = {
id: 'removeAvatarMutation' as const,
operationName: 'removeAvatar',
@@ -469,6 +536,44 @@ mutation signUp($name: String!, $email: String!, $password: String!) {
}`,
};
+export const subscriptionQuery = {
+ id: 'subscriptionQuery' as const,
+ operationName: 'subscription',
+ definitionName: 'currentUser',
+ containsFile: false,
+ query: `
+query subscription {
+ currentUser {
+ subscription {
+ id
+ status
+ plan
+ recurring
+ start
+ end
+ nextBillAt
+ canceledAt
+ }
+ }
+}`,
+};
+
+export const updateSubscriptionMutation = {
+ id: 'updateSubscriptionMutation' as const,
+ operationName: 'updateSubscription',
+ definitionName: 'updateSubscriptionRecurring',
+ containsFile: false,
+ query: `
+mutation updateSubscription($recurring: SubscriptionRecurring!) {
+ updateSubscriptionRecurring(recurring: $recurring) {
+ id
+ plan
+ recurring
+ nextBillAt
+ }
+}`,
+};
+
export const uploadAvatarMutation = {
id: 'uploadAvatarMutation' as const,
operationName: 'uploadAvatar',
diff --git a/packages/frontend/graphql/src/graphql/invoices.gql b/packages/frontend/graphql/src/graphql/invoices.gql
new file mode 100644
index 0000000000..569b77730f
--- /dev/null
+++ b/packages/frontend/graphql/src/graphql/invoices.gql
@@ -0,0 +1,15 @@
+query invoices($take: Int!, $skip: Int!) {
+ currentUser {
+ invoices(take: $take, skip: $skip) {
+ id
+ status
+ plan
+ recurring
+ currency
+ amount
+ reason
+ lastPaymentError
+ createdAt
+ }
+ }
+}
diff --git a/packages/frontend/graphql/src/graphql/prices.gql b/packages/frontend/graphql/src/graphql/prices.gql
new file mode 100644
index 0000000000..eb4a75e7bd
--- /dev/null
+++ b/packages/frontend/graphql/src/graphql/prices.gql
@@ -0,0 +1,9 @@
+query prices {
+ prices {
+ type
+ plan
+ currency
+ amount
+ yearlyAmount
+ }
+}
diff --git a/packages/frontend/graphql/src/graphql/subscription.gql b/packages/frontend/graphql/src/graphql/subscription.gql
new file mode 100644
index 0000000000..0072350f1f
--- /dev/null
+++ b/packages/frontend/graphql/src/graphql/subscription.gql
@@ -0,0 +1,14 @@
+query subscription {
+ currentUser {
+ subscription {
+ id
+ status
+ plan
+ recurring
+ start
+ end
+ nextBillAt
+ canceledAt
+ }
+ }
+}
diff --git a/packages/frontend/graphql/src/graphql/update-subscription-billing.gql b/packages/frontend/graphql/src/graphql/update-subscription-billing.gql
new file mode 100644
index 0000000000..1464d399d6
--- /dev/null
+++ b/packages/frontend/graphql/src/graphql/update-subscription-billing.gql
@@ -0,0 +1,8 @@
+mutation updateSubscription($recurring: SubscriptionRecurring!) {
+ updateSubscriptionRecurring(recurring: $recurring) {
+ id
+ plan
+ recurring
+ nextBillAt
+ }
+}
diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts
index 3a84e42fe4..e38e9b760c 100644
--- a/packages/frontend/graphql/src/schema.ts
+++ b/packages/frontend/graphql/src/schema.ts
@@ -130,6 +130,21 @@ export type AllBlobSizesQuery = {
collectAllBlobSizes: { __typename?: 'WorkspaceBlobSizes'; size: number };
};
+export type CancelSubscriptionMutationVariables = Exact<{
+ [key: string]: never;
+}>;
+
+export type CancelSubscriptionMutation = {
+ __typename?: 'Mutation';
+ cancelSubscription: {
+ __typename?: 'UserSubscription';
+ id: string;
+ status: SubscriptionStatus;
+ nextBillAt: string | null;
+ canceledAt: string | null;
+ };
+};
+
export type ChangeEmailMutationVariables = Exact<{
token: Scalars['String']['input'];
}>;
@@ -161,6 +176,12 @@ export type ChangePasswordMutation = {
};
};
+export type CheckoutMutationVariables = Exact<{
+ recurring: SubscriptionRecurring;
+}>;
+
+export type CheckoutMutation = { __typename?: 'Mutation'; checkout: string };
+
export type CreateWorkspaceMutationVariables = Exact<{
init: Scalars['Upload']['input'];
}>;
@@ -328,6 +349,30 @@ export type GetWorkspacesQuery = {
workspaces: Array<{ __typename?: 'WorkspaceType'; id: string }>;
};
+export type InvoicesQueryVariables = Exact<{
+ take: Scalars['Int']['input'];
+ skip: Scalars['Int']['input'];
+}>;
+
+export type InvoicesQuery = {
+ __typename?: 'Query';
+ currentUser: {
+ __typename?: 'UserType';
+ invoices: Array<{
+ __typename?: 'UserInvoice';
+ id: string;
+ status: InvoiceStatus;
+ plan: SubscriptionPlan;
+ recurring: SubscriptionRecurring;
+ currency: string;
+ amount: number;
+ reason: string;
+ lastPaymentError: string | null;
+ createdAt: string;
+ }>;
+ } | null;
+};
+
export type LeaveWorkspaceMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
workspaceName: Scalars['String']['input'];
@@ -339,6 +384,20 @@ export type LeaveWorkspaceMutation = {
leaveWorkspace: boolean;
};
+export type PricesQueryVariables = Exact<{ [key: string]: never }>;
+
+export type PricesQuery = {
+ __typename?: 'Query';
+ prices: Array<{
+ __typename?: 'SubscriptionPrice';
+ type: string;
+ plan: SubscriptionPlan;
+ currency: string;
+ amount: number;
+ yearlyAmount: number;
+ }>;
+};
+
export type RemoveAvatarMutationVariables = Exact<{ [key: string]: never }>;
export type RemoveAvatarMutation = {
@@ -451,6 +510,41 @@ export type SignUpMutation = {
};
};
+export type SubscriptionQueryVariables = Exact<{ [key: string]: never }>;
+
+export type SubscriptionQuery = {
+ __typename?: 'Query';
+ currentUser: {
+ __typename?: 'UserType';
+ subscription: {
+ __typename?: 'UserSubscription';
+ id: string;
+ status: SubscriptionStatus;
+ plan: SubscriptionPlan;
+ recurring: SubscriptionRecurring;
+ start: string;
+ end: string;
+ nextBillAt: string | null;
+ canceledAt: string | null;
+ } | null;
+ } | null;
+};
+
+export type UpdateSubscriptionMutationVariables = Exact<{
+ recurring: SubscriptionRecurring;
+}>;
+
+export type UpdateSubscriptionMutation = {
+ __typename?: 'Mutation';
+ updateSubscriptionRecurring: {
+ __typename?: 'UserSubscription';
+ id: string;
+ plan: SubscriptionPlan;
+ recurring: SubscriptionRecurring;
+ nextBillAt: string | null;
+ };
+};
+
export type UploadAvatarMutationVariables = Exact<{
avatar: Scalars['Upload']['input'];
}>;
@@ -570,6 +664,21 @@ export type Queries =
name: 'getWorkspacesQuery';
variables: GetWorkspacesQueryVariables;
response: GetWorkspacesQuery;
+ }
+ | {
+ name: 'invoicesQuery';
+ variables: InvoicesQueryVariables;
+ response: InvoicesQuery;
+ }
+ | {
+ name: 'pricesQuery';
+ variables: PricesQueryVariables;
+ response: PricesQuery;
+ }
+ | {
+ name: 'subscriptionQuery';
+ variables: SubscriptionQueryVariables;
+ response: SubscriptionQuery;
};
export type Mutations =
@@ -583,6 +692,11 @@ export type Mutations =
variables: SetBlobMutationVariables;
response: SetBlobMutation;
}
+ | {
+ name: 'cancelSubscriptionMutation';
+ variables: CancelSubscriptionMutationVariables;
+ response: CancelSubscriptionMutation;
+ }
| {
name: 'changeEmailMutation';
variables: ChangeEmailMutationVariables;
@@ -593,6 +707,11 @@ export type Mutations =
variables: ChangePasswordMutationVariables;
response: ChangePasswordMutation;
}
+ | {
+ name: 'checkoutMutation';
+ variables: CheckoutMutationVariables;
+ response: CheckoutMutation;
+ }
| {
name: 'createWorkspaceMutation';
variables: CreateWorkspaceMutationVariables;
@@ -668,6 +787,11 @@ export type Mutations =
variables: SignUpMutationVariables;
response: SignUpMutation;
}
+ | {
+ name: 'updateSubscriptionMutation';
+ variables: UpdateSubscriptionMutationVariables;
+ response: UpdateSubscriptionMutation;
+ }
| {
name: 'uploadAvatarMutation';
variables: UploadAvatarMutationVariables;
From 858a1da35fab4c71b53f5a38d0b1b073fb9b744a Mon Sep 17 00:00:00 2001
From: liuyi
Date: Fri, 20 Oct 2023 09:42:33 +0800
Subject: [PATCH 03/27] feat(core): impl billing settings (#4652)
---
.../migration.sql | 2 +
packages/backend/server/schema.prisma | 2 +
.../server/src/modules/payment/resolver.ts | 10 +
.../server/src/modules/payment/service.ts | 24 ++
packages/backend/server/src/schema.gql | 4 +
.../setting-components/setting-row.tsx | 14 +-
.../setting-components/share.css.ts | 1 -
.../general-setting/billing/index.tsx | 272 ++++++++++++++++++
.../general-setting/billing/style.css.ts | 39 +++
.../setting-modal/general-setting/index.tsx | 29 +-
.../general-setting/plans/index.tsx | 32 ++-
.../src/graphql/create-customer-portal.gql | 3 +
.../frontend/graphql/src/graphql/index.ts | 29 ++
.../frontend/graphql/src/graphql/invoices.gql | 1 +
.../src/graphql/resume-subscription.gql | 9 +
packages/frontend/graphql/src/schema.ts | 36 +++
16 files changed, 480 insertions(+), 27 deletions(-)
create mode 100644 packages/backend/server/migrations/20231019094615_add_inovice_link/migration.sql
create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx
create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/style.css.ts
create mode 100644 packages/frontend/graphql/src/graphql/create-customer-portal.gql
create mode 100644 packages/frontend/graphql/src/graphql/resume-subscription.gql
diff --git a/packages/backend/server/migrations/20231019094615_add_inovice_link/migration.sql b/packages/backend/server/migrations/20231019094615_add_inovice_link/migration.sql
new file mode 100644
index 0000000000..91729f5aa2
--- /dev/null
+++ b/packages/backend/server/migrations/20231019094615_add_inovice_link/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "user_invoices" ADD COLUMN "link" TEXT;
diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma
index bea9b7f8d5..41c4dab28d 100644
--- a/packages/backend/server/schema.prisma
+++ b/packages/backend/server/schema.prisma
@@ -224,6 +224,8 @@ model UserInvoice {
// billing reason
reason String @db.VarChar
lastPaymentError String? @map("last_payment_error") @db.Text
+ // stripe hosted invoice link
+ link String? @db.Text
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
diff --git a/packages/backend/server/src/modules/payment/resolver.ts b/packages/backend/server/src/modules/payment/resolver.ts
index e5e1a77a3c..84e926ded6 100644
--- a/packages/backend/server/src/modules/payment/resolver.ts
+++ b/packages/backend/server/src/modules/payment/resolver.ts
@@ -117,6 +117,9 @@ class UserInvoiceType implements Partial {
@Field(() => String, { nullable: true })
lastPaymentError?: string | null;
+ @Field(() => String, { nullable: true })
+ link?: string | null;
+
@Field(() => Date)
createdAt!: Date;
@@ -183,6 +186,13 @@ export class SubscriptionResolver {
return session.url;
}
+ @Mutation(() => String, {
+ description: 'Create a stripe customer portal to manage payment methods',
+ })
+ async createCustomerPortal(@CurrentUser() user: User) {
+ return this.service.createCustomerPortal(user.id);
+ }
+
@Mutation(() => UserSubscriptionType)
async cancelSubscription(@CurrentUser() user: User) {
return this.service.cancelSubscription(user.id);
diff --git a/packages/backend/server/src/modules/payment/service.ts b/packages/backend/server/src/modules/payment/service.ts
index 8f9771b1bd..018c81b435 100644
--- a/packages/backend/server/src/modules/payment/service.ts
+++ b/packages/backend/server/src/modules/payment/service.ts
@@ -296,6 +296,29 @@ export class SubscriptionService {
}
}
+ async createCustomerPortal(id: string) {
+ const user = await this.db.userStripeCustomer.findUnique({
+ where: {
+ userId: id,
+ },
+ });
+
+ if (!user) {
+ throw new Error('Unknown user');
+ }
+
+ try {
+ const portal = await this.stripe.billingPortal.sessions.create({
+ customer: user.stripeCustomerId,
+ });
+
+ return portal.url;
+ } catch (e) {
+ this.logger.error('Failed to create customer portal.', e);
+ throw new Error('Failed to create customer portal');
+ }
+ }
+
@OnEvent('customer.subscription.created')
@OnEvent('customer.subscription.updated')
async onSubscriptionChanges(subscription: Stripe.Subscription) {
@@ -519,6 +542,7 @@ export class SubscriptionService {
currency: stripeInvoice.currency,
amount: stripeInvoice.total,
status: stripeInvoice.status ?? InvoiceStatus.Void,
+ link: stripeInvoice.hosted_invoice_url,
};
// handle payment error
diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql
index 6be3ba621e..5b62bdaa07 100644
--- a/packages/backend/server/src/schema.gql
+++ b/packages/backend/server/src/schema.gql
@@ -112,6 +112,7 @@ type UserInvoice {
status: InvoiceStatus!
reason: String!
lastPaymentError: String
+ link: String
createdAt: DateTime!
updatedAt: DateTime!
}
@@ -278,6 +279,9 @@ type Mutation {
"""Create a subscription checkout link of stripe"""
checkout(recurring: SubscriptionRecurring!): String!
+
+ """Create a stripe customer portal to manage payment methods"""
+ createCustomerPortal: String!
cancelSubscription: UserSubscription!
resumeSubscription: UserSubscription!
updateSubscriptionRecurring(recurring: SubscriptionRecurring!): UserSubscription!
diff --git a/packages/frontend/component/src/components/setting-components/setting-row.tsx b/packages/frontend/component/src/components/setting-components/setting-row.tsx
index 4a6dc3f65f..df0a100709 100644
--- a/packages/frontend/component/src/components/setting-components/setting-row.tsx
+++ b/packages/frontend/component/src/components/setting-components/setting-row.tsx
@@ -11,6 +11,7 @@ export type SettingRowProps = PropsWithChildren<{
spreadCol?: boolean;
'data-testid'?: string;
disabled?: boolean;
+ className?: string;
}>;
export const SettingRow = ({
@@ -21,14 +22,19 @@ export const SettingRow = ({
style,
spreadCol = true,
disabled = false,
+ className,
...props
}: PropsWithChildren) => {
return (
{
+ const status = useCurrentLoginStatus();
+
+ if (status !== 'authenticated') {
+ return null;
+ }
+
+ return (
+ <>
+
+ {/* TODO: loading fallback */}
+
+
+
+
+
+ {/* TODO: loading fallback */}
+
+
+
+
+
+ >
+ );
+};
+
+const SubscriptionSettings = () => {
+ const { data: subscriptionQueryResult } = useQuery({
+ query: subscriptionQuery,
+ });
+ const { data: pricesQueryResult } = useQuery({
+ query: pricesQuery,
+ });
+
+ const subscription = subscriptionQueryResult.currentUser?.subscription;
+ const plan = subscription?.plan ?? SubscriptionPlan.Free;
+ const recurring = subscription?.recurring ?? SubscriptionRecurring.Monthly;
+
+ const price = pricesQueryResult.prices.find(price => price.plan === plan);
+ const amount =
+ plan === SubscriptionPlan.Free
+ ? '0'
+ : price
+ ? recurring === SubscriptionRecurring.Monthly
+ ? String(price.amount / 100)
+ : (price.yearlyAmount / 100 / 12).toFixed(2)
+ : '?';
+
+ return (
+
+
+ {subscription?.status === SubscriptionStatus.Active && (
+ <>
+
+
+
+ {subscription.nextBillAt && (
+
+ )}
+ {subscription.canceledAt ? (
+
+
+
+ ) : (
+
+
+
+ )}
+ >
+ )}
+
+ );
+};
+
+const PlanAction = ({ plan }: { plan: string }) => {
+ const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
+
+ const gotoPlansSetting = useCallback(() => {
+ setOpenSettingModalAtom({
+ open: true,
+ activeTab: 'plans',
+ workspaceId: null,
+ });
+ }, [setOpenSettingModalAtom]);
+
+ return (
+
+ {plan === SubscriptionPlan.Free ? 'Upgrade' : 'Change Plan'}
+
+ );
+};
+
+const PaymentMethodUpdater = () => {
+ // TODO: open stripe customer portal
+ const { isMutating, trigger, data } = useMutation({
+ mutation: createCustomerPortalMutation,
+ });
+
+ const update = useCallback(() => {
+ trigger();
+ }, [trigger]);
+
+ useEffect(() => {
+ if (data?.createCustomerPortal) {
+ window.open(data.createCustomerPortal, '_blank', 'noopener noreferrer');
+ }
+ }, [data]);
+
+ return (
+
+ Update
+
+ );
+};
+
+const ResumeSubscription = () => {
+ const { isMutating, trigger } = useMutation({
+ mutation: resumeSubscriptionMutation,
+ });
+
+ const resume = useCallback(() => {
+ trigger();
+ }, [trigger]);
+
+ return (
+
+ Resume
+
+ );
+};
+
+const CancelSubscription = () => {
+ const { isMutating, trigger } = useMutation({
+ mutation: cancelSubscriptionMutation,
+ });
+
+ const cancel = useCallback(() => {
+ trigger();
+ }, [trigger]);
+
+ return (
+
}
+ disabled={isMutating}
+ loading={isMutating}
+ onClick={cancel /* TODO: popup confirmation modal instead */}
+ />
+ );
+};
+
+const BillingHistory = () => {
+ const { data: invoicesQueryResult } = useQuery({
+ query: invoicesQuery,
+ variables: {
+ skip: 0,
+ take: 12,
+ },
+ });
+
+ const invoices = invoicesQueryResult.currentUser?.invoices ?? [];
+
+ return (
+
+ {invoices.length === 0 ? (
+
There are no invoices to display.
+ ) : (
+ // TODO: pagination
+ invoices.map(invoice => (
+
+ ))
+ )}
+
+ );
+};
+
+const InvoiceLine = ({
+ invoice,
+}: {
+ invoice: NonNullable
['invoices'][0];
+}) => {
+ const open = useCallback(() => {
+ if (invoice.link) {
+ window.open(invoice.link, '_blank', 'noopener noreferrer');
+ }
+ }, [invoice.link]);
+ return (
+ $, cny => ¥
+ desc={`${invoice.status === InvoiceStatus.Paid ? 'Paid' : ''} $${
+ invoice.amount / 100
+ }`}
+ >
+ View Invoice
+
+ );
+};
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/style.css.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/style.css.ts
new file mode 100644
index 0000000000..74f87f2c5e
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/style.css.ts
@@ -0,0 +1,39 @@
+import { globalStyle, style } from '@vanilla-extract/css';
+
+export const subscription = style({});
+
+export const billingHistory = style({});
+
+export const planCard = style({
+ display: 'flex',
+ justifyContent: 'space-between',
+ padding: '12px',
+ border: '1px solid var(--affine-border-color)',
+ borderRadius: '8px',
+});
+
+export const currentPlan = style({
+ flex: '1 0 0',
+});
+
+export const planAction = style({
+ marginTop: '8px',
+});
+
+export const planPrice = style({
+ fontSize: 'var(--affine-font-h-6)',
+ fontWeight: 600,
+});
+
+export const paymentMethod = style({
+ marginTop: '24px',
+});
+
+globalStyle('.dangerous-setting .name', {
+ color: 'var(--affine-error-color)',
+});
+
+export const noInvoice = style({
+ color: 'var(--affine-text-secondary-color)',
+ fontSize: 'var(--affine-font-xs)',
+});
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx
index 9a1c329fd4..97012598b5 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx
@@ -7,8 +7,10 @@ import {
} from '@blocksuite/icons';
import type { ReactElement, SVGProps } from 'react';
+import { useCurrentLoginStatus } from '../../../../hooks/affine/use-current-login-status';
import { AboutAffine } from './about';
import { AppearanceSettings } from './appearance';
+import { BillingSettings } from './billing';
import { AFFiNECloudPlans } from './plans';
import { Plugins } from './plugins';
import { Shortcuts } from './shortcuts';
@@ -32,8 +34,9 @@ export type GeneralSettingList = GeneralSettingListItem[];
export const useGeneralSettingList = (): GeneralSettingList => {
const t = useAFFiNEI18N();
+ const status = useCurrentLoginStatus();
- return [
+ const settings: GeneralSettingListItem[] = [
{
key: 'appearance',
title: t['com.affine.settings.appearance'](),
@@ -54,14 +57,7 @@ export const useGeneralSettingList = (): GeneralSettingList => {
icon: KeyboardIcon,
testId: 'plans-panel-trigger',
},
- {
- key: 'billing',
- // TODO: i18n
- title: 'Billing',
- // TODO: icon
- icon: KeyboardIcon,
- testId: 'billing-panel-trigger',
- },
+
{
key: 'plugins',
title: 'Plugins',
@@ -75,6 +71,19 @@ export const useGeneralSettingList = (): GeneralSettingList => {
testId: 'about-panel-trigger',
},
];
+
+ if (status === 'authenticated') {
+ settings.splice(3, 0, {
+ key: 'billing',
+ // TODO: i18n
+ title: 'Billing',
+ // TODO: icon
+ icon: KeyboardIcon,
+ testId: 'billing-panel-trigger',
+ });
+ }
+
+ return settings;
};
interface GeneralSettingProps {
@@ -93,6 +102,8 @@ export const GeneralSetting = ({ generalKey }: GeneralSettingProps) => {
return ;
case 'plans':
return ;
+ case 'billing':
+ return ;
default:
return null;
}
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx
index 1c79de89f2..cf80164f97 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx
@@ -129,12 +129,11 @@ const Settings = () => {
const subscription = data.currentUser?.subscription;
const [recurring, setRecurring] = useState(
- subscription?.recurring ?? SubscriptionRecurring.Monthly
+ subscription?.recurring ?? SubscriptionRecurring.Yearly
);
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
- const currentRecurring =
- subscription?.recurring ?? SubscriptionRecurring.Monthly;
+ const currentRecurring = subscription?.recurring;
const refresh = useCallback(() => {
mutate();
@@ -178,8 +177,6 @@ const Settings = () => {
{/* TODO: may scroll current plan into view when first loading? */}
{Array.from(planDetail.values()).map(detail => {
- const isCurrent =
- currentPlan === detail.plan && currentRecurring === recurring;
return (
{
{detail.plan}{' '}
- {'discount' in detail && (
-
- {detail.discount}% off
-
- )}
+ {'discount' in detail &&
+ recurring === SubscriptionRecurring.Yearly && (
+
+ {detail.discount}% off
+
+ )}
@@ -224,18 +222,26 @@ const Settings = () => {
detail.type === 'dynamic' ? (
) : loggedIn ? (
- isCurrent ? (
+ detail.plan === currentPlan &&
+ (currentRecurring === recurring ||
+ (!currentRecurring &&
+ detail.plan === SubscriptionPlan.Free)) ? (
) : detail.plan === SubscriptionPlan.Free ? (
- ) : currentRecurring !== recurring ? (
+ ) : currentRecurring !== recurring &&
+ currentPlan === detail.plan ? (
) : (
-
+
)
) : (
diff --git a/packages/frontend/graphql/src/graphql/create-customer-portal.gql b/packages/frontend/graphql/src/graphql/create-customer-portal.gql
new file mode 100644
index 0000000000..d6e268172c
--- /dev/null
+++ b/packages/frontend/graphql/src/graphql/create-customer-portal.gql
@@ -0,0 +1,3 @@
+mutation createCustomerPortal {
+ createCustomerPortal
+}
diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts
index 29330c8fed..5817251585 100644
--- a/packages/frontend/graphql/src/graphql/index.ts
+++ b/packages/frontend/graphql/src/graphql/index.ts
@@ -138,6 +138,17 @@ mutation checkout($recurring: SubscriptionRecurring!) {
}`,
};
+export const createCustomerPortalMutation = {
+ id: 'createCustomerPortalMutation' as const,
+ operationName: 'createCustomerPortal',
+ definitionName: 'createCustomerPortal',
+ containsFile: false,
+ query: `
+mutation createCustomerPortal {
+ createCustomerPortal
+}`,
+};
+
export const createWorkspaceMutation = {
id: 'createWorkspaceMutation' as const,
operationName: 'createWorkspace',
@@ -365,6 +376,7 @@ query invoices($take: Int!, $skip: Int!) {
amount
reason
lastPaymentError
+ link
createdAt
}
}
@@ -416,6 +428,23 @@ mutation removeAvatar {
}`,
};
+export const resumeSubscriptionMutation = {
+ id: 'resumeSubscriptionMutation' as const,
+ operationName: 'resumeSubscription',
+ definitionName: 'resumeSubscription',
+ containsFile: false,
+ query: `
+mutation resumeSubscription {
+ resumeSubscription {
+ id
+ status
+ nextBillAt
+ start
+ end
+ }
+}`,
+};
+
export const revokeMemberPermissionMutation = {
id: 'revokeMemberPermissionMutation' as const,
operationName: 'revokeMemberPermission',
diff --git a/packages/frontend/graphql/src/graphql/invoices.gql b/packages/frontend/graphql/src/graphql/invoices.gql
index 569b77730f..bd26f0896a 100644
--- a/packages/frontend/graphql/src/graphql/invoices.gql
+++ b/packages/frontend/graphql/src/graphql/invoices.gql
@@ -9,6 +9,7 @@ query invoices($take: Int!, $skip: Int!) {
amount
reason
lastPaymentError
+ link
createdAt
}
}
diff --git a/packages/frontend/graphql/src/graphql/resume-subscription.gql b/packages/frontend/graphql/src/graphql/resume-subscription.gql
new file mode 100644
index 0000000000..b56cb767bf
--- /dev/null
+++ b/packages/frontend/graphql/src/graphql/resume-subscription.gql
@@ -0,0 +1,9 @@
+mutation resumeSubscription {
+ resumeSubscription {
+ id
+ status
+ nextBillAt
+ start
+ end
+ }
+}
diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts
index e38e9b760c..cea6064754 100644
--- a/packages/frontend/graphql/src/schema.ts
+++ b/packages/frontend/graphql/src/schema.ts
@@ -182,6 +182,15 @@ export type CheckoutMutationVariables = Exact<{
export type CheckoutMutation = { __typename?: 'Mutation'; checkout: string };
+export type CreateCustomerPortalMutationVariables = Exact<{
+ [key: string]: never;
+}>;
+
+export type CreateCustomerPortalMutation = {
+ __typename?: 'Mutation';
+ createCustomerPortal: string;
+};
+
export type CreateWorkspaceMutationVariables = Exact<{
init: Scalars['Upload']['input'];
}>;
@@ -368,6 +377,7 @@ export type InvoicesQuery = {
amount: number;
reason: string;
lastPaymentError: string | null;
+ link: string | null;
createdAt: string;
}>;
} | null;
@@ -405,6 +415,22 @@ export type RemoveAvatarMutation = {
removeAvatar: { __typename?: 'RemoveAvatar'; success: boolean };
};
+export type ResumeSubscriptionMutationVariables = Exact<{
+ [key: string]: never;
+}>;
+
+export type ResumeSubscriptionMutation = {
+ __typename?: 'Mutation';
+ resumeSubscription: {
+ __typename?: 'UserSubscription';
+ id: string;
+ status: SubscriptionStatus;
+ nextBillAt: string | null;
+ start: string;
+ end: string;
+ };
+};
+
export type RevokeMemberPermissionMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
userId: Scalars['String']['input'];
@@ -712,6 +738,11 @@ export type Mutations =
variables: CheckoutMutationVariables;
response: CheckoutMutation;
}
+ | {
+ name: 'createCustomerPortalMutation';
+ variables: CreateCustomerPortalMutationVariables;
+ response: CreateCustomerPortalMutation;
+ }
| {
name: 'createWorkspaceMutation';
variables: CreateWorkspaceMutationVariables;
@@ -737,6 +768,11 @@ export type Mutations =
variables: RemoveAvatarMutationVariables;
response: RemoveAvatarMutation;
}
+ | {
+ name: 'resumeSubscriptionMutation';
+ variables: ResumeSubscriptionMutationVariables;
+ response: ResumeSubscriptionMutation;
+ }
| {
name: 'revokeMemberPermissionMutation';
variables: RevokeMemberPermissionMutationVariables;
From 95d37fc63fd6ea8818b80a258257765fdd488b54 Mon Sep 17 00:00:00 2001
From: liuyi
Date: Fri, 20 Oct 2023 16:03:28 +0800
Subject: [PATCH 04/27] refactor(core): make subscription hook (#4669)
---
.../general-setting/billing/index.tsx | 58 +++++----
.../general-setting/plans/index.tsx | 120 ++++++++++--------
.../core/src/hooks/use-subscription.ts | 40 ++++++
3 files changed, 146 insertions(+), 72 deletions(-)
create mode 100644 packages/frontend/core/src/hooks/use-subscription.ts
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx
index fa68c249e5..ce82b5b83b 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx
@@ -12,7 +12,6 @@ import {
pricesQuery,
resumeSubscriptionMutation,
SubscriptionPlan,
- subscriptionQuery,
SubscriptionRecurring,
SubscriptionStatus,
} from '@affine/graphql';
@@ -20,10 +19,14 @@ import { useMutation, useQuery } from '@affine/workspace/affine/gql';
import { ArrowRightSmallIcon } from '@blocksuite/icons';
import { Button, IconButton } from '@toeverything/components/button';
import { useSetAtom } from 'jotai';
-import { Suspense, useCallback, useEffect } from 'react';
+import { Suspense, useCallback } from 'react';
import { openSettingModalAtom } from '../../../../../atoms';
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
+import {
+ type SubscriptionMutator,
+ useUserSubscription,
+} from '../../../../../hooks/use-subscription';
import * as styles from './style.css';
export const BillingSettings = () => {
@@ -56,14 +59,11 @@ export const BillingSettings = () => {
};
const SubscriptionSettings = () => {
- const { data: subscriptionQueryResult } = useQuery({
- query: subscriptionQuery,
- });
+ const [subscription, mutateSubscription] = useUserSubscription();
const { data: pricesQueryResult } = useQuery({
query: pricesQuery,
});
- const subscription = subscriptionQueryResult.currentUser?.subscription;
const plan = subscription?.plan ?? SubscriptionPlan.Free;
const recurring = subscription?.recurring ?? SubscriptionRecurring.Monthly;
@@ -123,7 +123,7 @@ const SubscriptionSettings = () => {
subscription.end
).toLocaleDateString()}`}
>
-
+
) : (
{
subscription.end
).toLocaleDateString()}`}
>
-
+
)}
>
@@ -166,20 +166,18 @@ const PlanAction = ({ plan }: { plan: string }) => {
const PaymentMethodUpdater = () => {
// TODO: open stripe customer portal
- const { isMutating, trigger, data } = useMutation({
+ const { isMutating, trigger } = useMutation({
mutation: createCustomerPortalMutation,
});
const update = useCallback(() => {
- trigger();
+ trigger(null, {
+ onSuccess: data => {
+ window.open(data.createCustomerPortal, '_blank', 'noopener noreferrer');
+ },
+ });
}, [trigger]);
- useEffect(() => {
- if (data?.createCustomerPortal) {
- window.open(data.createCustomerPortal, '_blank', 'noopener noreferrer');
- }
- }, [data]);
-
return (
Update
@@ -187,14 +185,22 @@ const PaymentMethodUpdater = () => {
);
};
-const ResumeSubscription = () => {
+const ResumeSubscription = ({
+ onSubscriptionUpdate,
+}: {
+ onSubscriptionUpdate: SubscriptionMutator;
+}) => {
const { isMutating, trigger } = useMutation({
mutation: resumeSubscriptionMutation,
});
const resume = useCallback(() => {
- trigger();
- }, [trigger]);
+ trigger(null, {
+ onSuccess: data => {
+ onSubscriptionUpdate(data.resumeSubscription);
+ },
+ });
+ }, [trigger, onSubscriptionUpdate]);
return (
@@ -203,14 +209,22 @@ const ResumeSubscription = () => {
);
};
-const CancelSubscription = () => {
+const CancelSubscription = ({
+ onSubscriptionUpdate,
+}: {
+ onSubscriptionUpdate: SubscriptionMutator;
+}) => {
const { isMutating, trigger } = useMutation({
mutation: cancelSubscriptionMutation,
});
const cancel = useCallback(() => {
- trigger();
- }, [trigger]);
+ trigger(null, {
+ onSuccess: data => {
+ onSubscriptionUpdate(data.cancelSubscription);
+ },
+ });
+ }, [trigger, onSubscriptionUpdate]);
return (
([
]);
const Settings = () => {
- const { data, mutate } = useQuery({
- query: subscriptionQuery,
- });
+ const [subscription, mutateSubscription] = useUserSubscription();
+ const loggedIn = useCurrentLoginStatus() === 'authenticated';
const {
data: { prices },
@@ -125,9 +128,6 @@ const Settings = () => {
}
});
- const loggedIn = !!data.currentUser;
- const subscription = data.currentUser?.subscription;
-
const [recurring, setRecurring] = useState(
subscription?.recurring ?? SubscriptionRecurring.Yearly
);
@@ -135,10 +135,6 @@ const Settings = () => {
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
const currentRecurring = subscription?.recurring;
- const refresh = useCallback(() => {
- mutate();
- }, [mutate]);
-
const yearlyDiscount = (
planDetail.get(SubscriptionPlan.Pro) as FixedPrice | undefined
)?.discount;
@@ -228,19 +224,19 @@ const Settings = () => {
detail.plan === SubscriptionPlan.Free)) ? (
) : detail.plan === SubscriptionPlan.Free ? (
-
+
) : currentRecurring !== recurring &&
currentPlan === detail.plan ? (
) : (
)
) : (
@@ -280,14 +276,22 @@ const Settings = () => {
);
};
-const Downgrade = ({ onActionDone }: { onActionDone: () => void }) => {
+const Downgrade = ({
+ onSubscriptionUpdate,
+}: {
+ onSubscriptionUpdate: SubscriptionMutator;
+}) => {
const { isMutating, trigger } = useMutation({
mutation: cancelSubscriptionMutation,
});
const downgrade = useCallback(() => {
- trigger(null, { onSuccess: onActionDone });
- }, [trigger, onActionDone]);
+ trigger(null, {
+ onSuccess: data => {
+ onSubscriptionUpdate(data.cancelSubscription);
+ },
+ });
+ }, [trigger, onSubscriptionUpdate]);
return (
void }) => {
const Upgrade = ({
recurring,
- onActionDone,
+ onSubscriptionUpdate,
}: {
recurring: SubscriptionRecurring;
- onActionDone: () => void;
+ onSubscriptionUpdate: SubscriptionMutator;
}) => {
- const { isMutating, trigger, data } = useMutation({
+ const { isMutating, trigger } = useMutation({
mutation: checkoutMutation,
});
- const upgrade = useCallback(() => {
- trigger({ recurring });
- }, [trigger, recurring]);
-
const newTabRef = useRef(null);
- useEffect(() => {
- if (data?.checkout) {
- if (newTabRef.current) {
- newTabRef.current.focus();
- } else {
- // FIXME: safari prevents from opening new tab by window api
- // TODO(@xp): what if electron?
- const newTab = window.open(
- data.checkout,
- '_blank',
- 'noopener noreferrer'
- );
+ const onClose = useCallback(() => {
+ newTabRef.current = null;
+ onSubscriptionUpdate();
+ }, [onSubscriptionUpdate]);
- if (newTab) {
- newTabRef.current = newTab;
- const update = () => {
- onActionDone();
- };
- newTab.addEventListener('close', update);
+ const upgrade = useCallback(() => {
+ if (newTabRef.current) {
+ newTabRef.current.focus();
+ } else {
+ trigger(
+ { recurring },
+ {
+ onSuccess: data => {
+ // FIXME: safari prevents from opening new tab by window api
+ // TODO(@xp): what if electron?
+ const newTab = window.open(
+ data.checkout,
+ '_blank',
+ 'noopener noreferrer'
+ );
- return () => newTab.removeEventListener('close', update);
+ if (newTab) {
+ newTabRef.current = newTab;
+
+ newTab.addEventListener('close', onClose);
+ }
+ },
}
- }
+ );
}
+ }, [trigger, recurring, onClose]);
- return;
- }, [data?.checkout, onActionDone]);
+ useEffect(() => {
+ return () => {
+ if (newTabRef.current) {
+ newTabRef.current.removeEventListener('close', onClose);
+ newTabRef.current = null;
+ }
+ };
+ }, [onClose]);
return (
void;
+ onSubscriptionUpdate: SubscriptionMutator;
}) => {
const { isMutating, trigger } = useMutation({
mutation: updateSubscriptionMutation,
});
const change = useCallback(() => {
- trigger({ recurring: to }, { onSuccess: onActionDone });
- }, [trigger, onActionDone, to]);
+ trigger(
+ { recurring: to },
+ {
+ onSuccess: data => {
+ onSubscriptionUpdate(data.updateSubscriptionRecurring);
+ },
+ }
+ );
+ }, [trigger, onSubscriptionUpdate, to]);
return (
['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;
+};
From 113b20f6695ab6bd2b4d6a2ee398fc0989bf8f22 Mon Sep 17 00:00:00 2001
From: liuyi
Date: Fri, 20 Oct 2023 16:51:17 +0800
Subject: [PATCH 05/27] fix(core): payment ui issues (#4672)
---
.../general-setting/billing/index.tsx | 2 +-
.../general-setting/plans/index.tsx | 42 ++++++++++++-------
2 files changed, 28 insertions(+), 16 deletions(-)
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx
index ce82b5b83b..146f982470 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx
@@ -44,7 +44,7 @@ export const BillingSettings = () => {
/>
{/* TODO: loading fallback */}
-
+
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx
index 9e488fd4a4..0c1e7af33b 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx
@@ -144,12 +144,18 @@ const Settings = () => {
- You are current on the {currentPlan} plan. If you have any
- questions, please contact our{' '}
- {/*TODO: add action*/}customer support .
-
+ loggedIn ? (
+
+ You are current on the {currentPlan} plan. If you have any
+ questions, please contact our{' '}
+ {/*TODO: add action*/}customer support .
+
+ ) : (
+
+ This is the Pricing plans of AFFiNE Cloud. You can sign up or sign
+ in to your account first.
+
+ )
}
/>
@@ -193,15 +199,21 @@ const Settings = () => {
)}
-
- $
- {detail.type === 'dynamic'
- ? '?'
- : recurring === SubscriptionRecurring.Monthly
- ? detail.price
- : detail.yearlyPrice}
-
- per month
+ {detail.type === 'dynamic' ? (
+
+ Coming soon...
+
+ ) : (
+ <>
+
+ $
+ {recurring === SubscriptionRecurring.Monthly
+ ? detail.price
+ : detail.yearlyPrice}
+
+ per month
+ >
+ )}
{
// branches:
From 303dade2efd246f1d949a09502cdae15805fef85 Mon Sep 17 00:00:00 2001
From: liuyi
Date: Mon, 23 Oct 2023 16:27:17 +0800
Subject: [PATCH 06/27] fix cancel subscription edge cases (#4691)
---
.../server/src/modules/payment/service.ts | 208 ++++++++----------
1 file changed, 97 insertions(+), 111 deletions(-)
diff --git a/packages/backend/server/src/modules/payment/service.ts b/packages/backend/server/src/modules/payment/service.ts
index 018c81b435..65fb78fb2e 100644
--- a/packages/backend/server/src/modules/payment/service.ts
+++ b/packages/backend/server/src/modules/payment/service.ts
@@ -93,7 +93,7 @@ export class SubscriptionService {
});
if (currentSubscription && currentSubscription.end < new Date()) {
- throw new Error('User already has a subscription');
+ throw new Error('You already have a subscription');
}
const prices = await this.stripe.prices.list({
@@ -137,11 +137,11 @@ export class SubscriptionService {
});
if (!user?.subscription) {
- throw new Error('User has no subscription');
+ throw new Error('You do not have any subscription');
}
if (user.subscription.canceledAt) {
- throw new Error('User subscription has already been canceled ');
+ throw new Error('Your subscription has already been canceled ');
}
// should release the schedule first
@@ -174,17 +174,15 @@ export class SubscriptionService {
});
if (!user?.subscription) {
- throw new Error('User has no subscription');
+ throw new Error('You do not have any subscription');
}
if (!user.subscription.canceledAt) {
- throw new Error('User subscription is not canceled');
+ throw new Error('Your subscription has not been canceled');
}
if (user.subscription.end < new Date()) {
- throw new Error(
- 'User subscription has already expired, please checkout again.'
- );
+ throw new Error('Your subscription is expired, please checkout again.');
}
const subscription = await this.stripe.subscriptions.update(
@@ -211,11 +209,15 @@ export class SubscriptionService {
});
if (!user?.subscription) {
- throw new Error('User has no 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('User has already subscribed to this plan');
+ throw new Error('You have already subscribed to this plan');
}
const prices = await this.stripe.prices.list({
@@ -344,29 +346,98 @@ export class SubscriptionService {
}
@OnEvent('invoice.created')
- async onInvoiceCreated(invoice: Stripe.Invoice) {
- await this.saveInvoice(invoice);
- }
-
@OnEvent('invoice.paid')
- async onInvoicePaid(invoice: Stripe.Invoice) {
- await this.saveInvoice(invoice);
- }
-
@OnEvent('invoice.finalization_failed')
- async onInvoiceFinalizeFailed(invoice: Stripe.Invoice) {
- await this.saveInvoice(invoice);
- }
-
@OnEvent('invoice.payment_failed')
- async onInvoicePaymentFailed(invoice: Stripe.Invoice) {
- await this.saveInvoice(invoice);
+ 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');
+ }
+
+ await this.db.userInvoice.create({
+ data: {
+ userId: user.id,
+ stripeInvoiceId: stripeInvoice.id,
+ plan: SubscriptionPlan.Pro,
+ recurring: price.lookup_key ?? price.id,
+ reason: stripeInvoice.billing_reason ?? 'contact support',
+ ...(data as any),
+ },
+ });
+ }
}
private async saveSubscription(
user: User,
- subscription: Stripe.Subscription
+ 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;
@@ -375,17 +446,7 @@ export class SubscriptionService {
subscription.status === SubscriptionStatus.Trialing) &&
!subscription.canceled_at
) {
- try {
- const nextInvoice = await this.stripe.invoices.retrieveUpcoming({
- customer: subscription.customer as string,
- subscription: subscription.id,
- });
-
- nextBillAt = new Date(nextInvoice.created * 1000);
- } catch (e) {
- // no upcoming invoice
- // safe to ignore
- }
+ nextBillAt = new Date(subscription.current_period_end * 1000);
}
const price = subscription.items.data[0].price;
@@ -522,79 +583,4 @@ export class SubscriptionService {
return user;
}
-
- private async saveInvoice(stripeInvoice: Stripe.Invoice) {
- if (!stripeInvoice.customer) {
- throw new Error('Unexpected invoice with no customer');
- }
-
- const user = await this.retrieveUserFromCustomer(
- stripeInvoice.customer as string
- );
-
- const invoice = await this.db.userInvoice.findUnique({
- where: {
- stripeInvoiceId: stripeInvoice.id,
- },
- });
-
- const data: Partial = {
- currency: stripeInvoice.currency,
- amount: stripeInvoice.total,
- status: stripeInvoice.status ?? InvoiceStatus.Void,
- 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');
- }
-
- await this.db.userInvoice.create({
- data: {
- userId: user.id,
- stripeInvoiceId: stripeInvoice.id,
- plan: SubscriptionPlan.Pro,
- recurring: price.lookup_key ?? price.id,
- reason: stripeInvoice.billing_reason ?? 'contact support',
- ...(data as any),
- },
- });
- }
- }
}
From 9b43380b0510e7f251fb7ddfe9f081afd328371f Mon Sep 17 00:00:00 2001
From: forehalo
Date: Mon, 23 Oct 2023 18:39:08 +0800
Subject: [PATCH 07/27] fix(server): cancel scheduled subscription
---
.../server/src/modules/payment/resolver.ts | 2 +-
.../server/src/modules/payment/service.ts | 298 +++++++++++++-----
2 files changed, 219 insertions(+), 81 deletions(-)
diff --git a/packages/backend/server/src/modules/payment/resolver.ts b/packages/backend/server/src/modules/payment/resolver.ts
index 84e926ded6..ffbffa6269 100644
--- a/packages/backend/server/src/modules/payment/resolver.ts
+++ b/packages/backend/server/src/modules/payment/resolver.ts
@@ -200,7 +200,7 @@ export class SubscriptionResolver {
@Mutation(() => UserSubscriptionType)
async resumeSubscription(@CurrentUser() user: User) {
- return this.service.resumeCanceledSubscriptin(user.id);
+ return this.service.resumeCanceledSubscription(user.id);
}
@Mutation(() => UserSubscriptionType)
diff --git a/packages/backend/server/src/modules/payment/service.ts b/packages/backend/server/src/modules/payment/service.ts
index 65fb78fb2e..213bed2501 100644
--- a/packages/backend/server/src/modules/payment/service.ts
+++ b/packages/backend/server/src/modules/payment/service.ts
@@ -50,6 +50,11 @@ export enum InvoiceStatus {
Uncollectible = 'uncollectible',
}
+export enum Coupon {
+ EarlyAccess = 'earlyaccess',
+ EarlyAccessRenew = 'earlyaccessrenew',
+}
+
@Injectable()
export class SubscriptionService {
private readonly paymentConfig: Config['payment'];
@@ -141,29 +146,32 @@ export class SubscriptionService {
}
if (user.subscription.canceledAt) {
- throw new Error('Your subscription has already been canceled ');
+ throw new Error('Your subscription has already been canceled');
}
// should release the schedule first
if (user.subscription.stripeScheduleId) {
- await this.stripe.subscriptionSchedules.release(
- user.subscription.stripeScheduleId
+ await this.cancelSubscriptionSchedule(user.subscription.stripeScheduleId);
+ return this.saveSubscription(
+ user,
+ await this.stripe.subscriptions.retrieve(
+ user.subscription.stripeSubscriptionId
+ )
);
+ } 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,
+ }
+ );
+ return await this.saveSubscription(user, subscription);
}
-
- // let customer contact support if they want to cancel immediately
- // see https://stripe.com/docs/billing/subscriptions/cancel
- const subscription = await this.stripe.subscriptions.update(
- user.subscription.stripeSubscriptionId,
- {
- cancel_at_period_end: true,
- }
- );
-
- return await this.saveSubscription(user, subscription);
}
- async resumeCanceledSubscriptin(userId: string): Promise {
+ async resumeCanceledSubscription(userId: string): Promise {
const user = await this.db.user.findUnique({
where: {
id: userId,
@@ -185,14 +193,24 @@ export class SubscriptionService {
throw new Error('Your subscription is expired, please checkout again.');
}
- const subscription = await this.stripe.subscriptions.update(
- user.subscription.stripeSubscriptionId,
- {
- cancel_at_period_end: false,
- }
- );
+ if (user.subscription.stripeScheduleId) {
+ await this.resumeSubscriptionSchedule(user.subscription.stripeScheduleId);
+ return this.saveSubscription(
+ user,
+ await this.stripe.subscriptions.retrieve(
+ user.subscription.stripeSubscriptionId
+ )
+ );
+ } else {
+ const subscription = await this.stripe.subscriptions.update(
+ user.subscription.stripeSubscriptionId,
+ {
+ cancel_at_period_end: false,
+ }
+ );
- return await this.saveSubscription(user, subscription);
+ return await this.saveSubscription(user, subscription);
+ }
}
async updateSubscriptionRecurring(
@@ -230,72 +248,30 @@ export class SubscriptionService {
const newPrice = prices.data[0];
+ let scheduleId: string | null;
// a schedule existing
if (user.subscription.stripeScheduleId) {
- const schedule = await this.stripe.subscriptionSchedules.retrieve(
- user.subscription.stripeScheduleId
+ scheduleId = await this.scheduleNewPrice(
+ user.subscription.stripeScheduleId,
+ newPrice.id
);
-
- // a scheduled subscription's old price equals the change
- if (
- schedule.phases[0] &&
- (schedule.phases[0].items[0].price as string) === newPrice.id
- ) {
- await this.stripe.subscriptionSchedules.release(
- user.subscription.stripeScheduleId
- );
-
- return await this.db.userSubscription.update({
- where: {
- id: user.subscription.id,
- },
- data: {
- recurring,
- },
- });
- } else {
- throw new Error(
- 'Unexpected subscription scheduled, please contact the supporters'
- );
- }
} else {
const schedule = await this.stripe.subscriptionSchedules.create({
from_subscription: user.subscription.stripeSubscriptionId,
});
-
- await this.stripe.subscriptionSchedules.update(schedule.id, {
- phases: [
- {
- items: [
- {
- price: schedule.phases[0].items[0].price as string,
- quantity: 1,
- },
- ],
- start_date: schedule.phases[0].start_date,
- end_date: schedule.phases[0].end_date,
- },
- {
- items: [
- {
- price: newPrice.id,
- quantity: 1,
- },
- ],
- },
- ],
- });
-
- return await this.db.userSubscription.update({
- where: {
- id: user.subscription.id,
- },
- data: {
- recurring,
- stripeScheduleId: schedule.id,
- },
- });
+ await this.scheduleNewPrice(schedule.id, newPrice.id);
+ scheduleId = schedule.id;
}
+
+ return await this.db.userSubscription.update({
+ where: {
+ id: user.subscription.id,
+ },
+ data: {
+ stripeScheduleId: scheduleId,
+ recurring,
+ },
+ });
}
async createCustomerPortal(id: string) {
@@ -583,4 +559,166 @@ export class SubscriptionService {
return user;
}
+
+ /**
+ * If a subscription is managed by a schedule, it has a different way to cancel.
+ */
+ private async cancelSubscriptionSchedule(scheduleId: string) {
+ const schedule =
+ await this.stripe.subscriptionSchedules.retrieve(scheduleId);
+
+ const currentPhase = schedule.phases.find(
+ phase =>
+ phase.start_date * 1000 < Date.now() &&
+ phase.end_date * 1000 > Date.now()
+ );
+
+ if (
+ schedule.status !== 'active' ||
+ schedule.phases.length > 2 ||
+ !currentPhase
+ ) {
+ throw new Error('Unexpected subscription schedule status');
+ }
+
+ if (schedule.status !== 'active') {
+ throw new Error('unexpected subscription schedule status');
+ }
+
+ const nextPhase = schedule.phases.find(
+ phase => phase.start_date * 1000 > Date.now()
+ );
+
+ if (!currentPhase) {
+ throw new Error('Unexpected subscription schedule status');
+ }
+
+ const update: Stripe.SubscriptionScheduleUpdateParams.Phase = {
+ items: [
+ {
+ price: currentPhase.items[0].price as string,
+ quantity: 1,
+ },
+ ],
+ coupon: (currentPhase.coupon as string | null) ?? undefined,
+ start_date: currentPhase.start_date,
+ end_date: currentPhase.end_date,
+ };
+
+ if (nextPhase) {
+ // cancel a subscription with a schedule exiting will delete the upcoming phase,
+ // it's hard to recover the subscription to the original state if user wan't to resume before due.
+ // so we manually save the next phase's key information to metadata for later easy resuming.
+ update.metadata = {
+ next_coupon: (nextPhase.coupon as string | null) || null, // avoid empty string
+ next_price: nextPhase.items[0].price as string,
+ };
+ }
+
+ await this.stripe.subscriptionSchedules.update(schedule.id, {
+ phases: [update],
+ end_behavior: 'cancel',
+ });
+ }
+
+ private async resumeSubscriptionSchedule(scheduleId: string) {
+ const schedule =
+ await this.stripe.subscriptionSchedules.retrieve(scheduleId);
+
+ const currentPhase = schedule.phases.find(
+ phase =>
+ phase.start_date * 1000 < Date.now() &&
+ phase.end_date * 1000 > Date.now()
+ );
+
+ if (schedule.status !== 'active' || !currentPhase) {
+ throw new Error('Unexpected subscription schedule status');
+ }
+
+ const update: Stripe.SubscriptionScheduleUpdateParams.Phase[] = [
+ {
+ items: [
+ {
+ price: currentPhase.items[0].price as string,
+ quantity: 1,
+ },
+ ],
+ coupon: (currentPhase.coupon as string | null) ?? undefined,
+ start_date: currentPhase.start_date,
+ end_date: currentPhase.end_date,
+ metadata: {
+ next_coupon: null,
+ next_price: null,
+ },
+ },
+ ];
+
+ if (currentPhase.metadata && currentPhase.metadata.next_price) {
+ update.push({
+ items: [
+ {
+ price: currentPhase.metadata.next_price,
+ quantity: 1,
+ },
+ ],
+ coupon: currentPhase.metadata.next_coupon || undefined,
+ });
+ }
+
+ await this.stripe.subscriptionSchedules.update(schedule.id, {
+ phases: update,
+ end_behavior: 'release',
+ });
+ }
+
+ /**
+ * we only schedule a new price when user change the recurring plan and there is now upcoming phases.
+ */
+ async scheduleNewPrice(
+ scheduleId: string,
+ priceId: string
+ ): Promise {
+ const schedule =
+ await this.stripe.subscriptionSchedules.retrieve(scheduleId);
+
+ const currentPhase = schedule.phases.find(
+ phase =>
+ phase.start_date * 1000 < Date.now() &&
+ phase.end_date * 1000 > Date.now()
+ );
+
+ if (schedule.status !== 'active' || !currentPhase) {
+ throw new Error('Unexpected subscription schedule status');
+ }
+
+ // if current phase's plan matches target, just release the schedule
+ if (currentPhase.items[0].price === priceId) {
+ await this.stripe.subscriptionSchedules.release(scheduleId);
+ return null;
+ } else {
+ await this.stripe.subscriptionSchedules.update(schedule.id, {
+ phases: [
+ {
+ items: [
+ {
+ price: currentPhase.items[0].price as string,
+ },
+ ],
+ start_date: schedule.phases[0].start_date,
+ end_date: schedule.phases[0].end_date,
+ },
+ {
+ items: [
+ {
+ price: priceId,
+ quantity: 1,
+ },
+ ],
+ },
+ ],
+ });
+
+ return scheduleId;
+ }
+ }
}
From 2e4f6ef2edbb14e9ee36303eb95e26a8b2abe7a2 Mon Sep 17 00:00:00 2001
From: forehalo
Date: Tue, 24 Oct 2023 11:22:27 +0800
Subject: [PATCH 08/27] feat(server): combine plan and recurring as stripe
lookup key
---
.../server/src/modules/payment/resolver.ts | 47 ++++++++---
.../server/src/modules/payment/service.ts | 81 ++++++++++++-------
2 files changed, 87 insertions(+), 41 deletions(-)
diff --git a/packages/backend/server/src/modules/payment/resolver.ts b/packages/backend/server/src/modules/payment/resolver.ts
index ffbffa6269..a7ef4b7cc4 100644
--- a/packages/backend/server/src/modules/payment/resolver.ts
+++ b/packages/backend/server/src/modules/payment/resolver.ts
@@ -16,12 +16,14 @@ import {
Resolver,
} from '@nestjs/graphql';
import type { User, UserInvoice, UserSubscription } from '@prisma/client';
+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,
@@ -140,26 +142,45 @@ export class SubscriptionResolver {
async prices(): Promise {
const prices = await this.service.listPrices();
- const yearly = prices.data.find(
- price => price.lookup_key === SubscriptionRecurring.Yearly
- );
- const monthly = prices.data.find(
- price => price.lookup_key === SubscriptionRecurring.Monthly
+ 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;
+ }
);
- if (!yearly || !monthly) {
- throw new BadGatewayException('The prices are not configured correctly');
- }
+ 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
+ );
- return [
- {
+ if (!yearly || !monthly) {
+ throw new BadGatewayException(
+ 'The prices are not configured correctly'
+ );
+ }
+
+ return {
type: 'fixed',
- plan: SubscriptionPlan.Pro,
+ plan: plan as SubscriptionPlan,
currency: monthly.currency,
amount: monthly.unit_amount ?? 0,
yearlyAmount: yearly.unit_amount ?? 0,
- },
- ];
+ };
+ });
}
@Mutation(() => String, {
diff --git a/packages/backend/server/src/modules/payment/service.ts b/packages/backend/server/src/modules/payment/service.ts
index 213bed2501..e724717777 100644
--- a/packages/backend/server/src/modules/payment/service.ts
+++ b/packages/backend/server/src/modules/payment/service.ts
@@ -17,7 +17,7 @@ const OnEvent = (
opts?: Parameters[1]
) => RawOnEvent(event, opts);
-// also used as lookup key for stripe prices
+// Plan x Recurring make a stripe price lookup key
export enum SubscriptionRecurring {
Monthly = 'monthly',
Yearly = 'yearly',
@@ -30,6 +30,21 @@ export enum SubscriptionPlan {
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',
@@ -77,17 +92,17 @@ export class SubscriptionService {
}
async listPrices() {
- return this.stripe.prices.list({
- lookup_keys: Object.values(SubscriptionRecurring),
- });
+ return this.stripe.prices.list();
}
async createCheckoutSession({
user,
recurring,
redirectUrl,
+ plan = SubscriptionPlan.Pro,
}: {
user: User;
+ plan?: SubscriptionPlan;
recurring: SubscriptionRecurring;
redirectUrl: string;
}) {
@@ -101,19 +116,13 @@ export class SubscriptionService {
throw new Error('You already have a subscription');
}
- const prices = await this.stripe.prices.list({
- lookup_keys: [recurring],
- });
-
- if (!prices.data.length) {
- throw new Error(`Unknown subscription recurring: ${recurring}`);
- }
+ const price = await this.getPrice(plan, recurring);
const customer = await this.getOrCreateCustomer(user);
return await this.stripe.checkout.sessions.create({
line_items: [
{
- price: prices.data[0].id,
+ price,
quantity: 1,
},
],
@@ -215,7 +224,7 @@ export class SubscriptionService {
async updateSubscriptionRecurring(
userId: string,
- recurring: string
+ recurring: SubscriptionRecurring
): Promise {
const user = await this.db.user.findUnique({
where: {
@@ -238,28 +247,23 @@ export class SubscriptionService {
throw new Error('You have already subscribed to this plan');
}
- const prices = await this.stripe.prices.list({
- lookup_keys: [recurring],
- });
-
- if (!prices.data.length) {
- throw new Error(`Unknown subscription recurring: ${recurring}`);
- }
-
- const newPrice = prices.data[0];
+ const price = await this.getPrice(
+ user.subscription.plan as SubscriptionPlan,
+ recurring
+ );
let scheduleId: string | null;
// a schedule existing
if (user.subscription.stripeScheduleId) {
scheduleId = await this.scheduleNewPrice(
user.subscription.stripeScheduleId,
- newPrice.id
+ price
);
} else {
const schedule = await this.stripe.subscriptionSchedules.create({
from_subscription: user.subscription.stripeSubscriptionId,
});
- await this.scheduleNewPrice(schedule.id, newPrice.id);
+ await this.scheduleNewPrice(schedule.id, price);
scheduleId = schedule.id;
}
@@ -426,6 +430,11 @@ export class SubscriptionService {
}
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),
@@ -441,9 +450,8 @@ export class SubscriptionService {
? new Date(subscription.canceled_at * 1000)
: null,
stripeSubscriptionId: subscription.id,
- recurring: price.lookup_key ?? price.id,
- // TODO: dynamic plans
- plan: SubscriptionPlan.Pro,
+ plan,
+ recurring,
status: subscription.status,
stripeScheduleId: subscription.schedule as string | null,
};
@@ -560,6 +568,23 @@ export class SubscriptionService {
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;
+ }
+
/**
* If a subscription is managed by a schedule, it has a different way to cancel.
*/
@@ -674,7 +699,7 @@ export class SubscriptionService {
/**
* we only schedule a new price when user change the recurring plan and there is now upcoming phases.
*/
- async scheduleNewPrice(
+ private async scheduleNewPrice(
scheduleId: string,
priceId: string
): Promise {
From 7ecee01d2078736982e74f186548dc34780f6ee7 Mon Sep 17 00:00:00 2001
From: forehalo
Date: Tue, 24 Oct 2023 11:46:29 +0800
Subject: [PATCH 09/27] fix(server): respond stripe webhook immediately
---
.../backend/server/src/modules/payment/webhook.ts | 11 ++++++++---
1 file changed, 8 insertions(+), 3 deletions(-)
diff --git a/packages/backend/server/src/modules/payment/webhook.ts b/packages/backend/server/src/modules/payment/webhook.ts
index 785ea97832..1fda0b6411 100644
--- a/packages/backend/server/src/modules/payment/webhook.ts
+++ b/packages/backend/server/src/modules/payment/webhook.ts
@@ -64,9 +64,14 @@ export class StripeWebhook {
`[${event.id}] Stripe Webhook {${event.type}} received.`
);
- // handle duplicated events?
- // see https://stripe.com/docs/webhooks#handle-duplicate-events
- await this.event.emitAsync(event.type, event.data.object);
+ // 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();
From df77ffde9a0c202977079499c278143fd59573bb Mon Sep 17 00:00:00 2001
From: JimmFly
Date: Tue, 24 Oct 2023 18:05:56 +0800
Subject: [PATCH 10/27] feat(core): add account subscription status (#4707)
---
.../src/components/affine/auth/style.css.ts | 14 ++++++++
.../affine/auth/user-plan-button.tsx | 34 +++++++++++++++++++
.../setting-modal/setting-sidebar/index.tsx | 9 +++--
.../setting-sidebar/style.css.ts | 12 +++++--
.../user-account/index.css.ts | 12 +++++--
.../user-account/index.tsx | 6 +++-
6 files changed, 80 insertions(+), 7 deletions(-)
create mode 100644 packages/frontend/core/src/components/affine/auth/user-plan-button.tsx
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..be9e4ca8ae
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx
@@ -0,0 +1,34 @@
+import { SubscriptionPlan } from '@affine/graphql';
+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]
+ );
+
+ return (
+
+
+ {plan}
+
+
+ );
+};
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.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/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}
+
}
contentOptions={{
From 97d06432f00fc9ce40f24479c4a1eb864f27de98 Mon Sep 17 00:00:00 2001
From: liuyi
Date: Tue, 24 Oct 2023 18:32:52 +0800
Subject: [PATCH 11/27] fix(server): wrong invoice recurring value saved
(#4712)
---
packages/backend/server/src/modules/payment/service.ts | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/packages/backend/server/src/modules/payment/service.ts b/packages/backend/server/src/modules/payment/service.ts
index e724717777..e9e4374b0b 100644
--- a/packages/backend/server/src/modules/payment/service.ts
+++ b/packages/backend/server/src/modules/payment/service.ts
@@ -393,12 +393,18 @@ export class SubscriptionService {
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: SubscriptionPlan.Pro,
- recurring: price.lookup_key ?? price.id,
+ plan,
+ recurring,
reason: stripeInvoice.billing_reason ?? 'contact support',
...(data as any),
},
From 374912590705bc05d78e3ea5c5199f17bbf83e3f Mon Sep 17 00:00:00 2001
From: Cats Juice
Date: Tue, 24 Oct 2023 18:38:31 +0800
Subject: [PATCH 12/27] feat(core): full width scroll area for plans (#4708)
---
.../general-setting/plans/style.css.ts | 14 +++++++
.../components/affine/setting-modal/index.tsx | 42 ++++++++++++++++++-
.../affine/setting-modal/style.css.ts | 1 -
3 files changed, 54 insertions(+), 3 deletions(-)
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts
index d12bb790eb..636169e78e 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts
@@ -10,12 +10,18 @@ export const recurringRadioGroup = style({
export const radioButtonDiscount = style({
marginLeft: '4px',
color: 'var(--affine-primary-color)',
+ fontWeight: 400,
});
export const planCardsWrapper = style({
+ marginLeft: 'calc(-1 * var(--setting-modal-gap-x))',
+ paddingLeft: 'var(--setting-modal-gap-x)',
+ paddingRight: 'calc(var(--setting-modal-gap-x) + 300px)',
+ width: 'var(--setting-modal-width)',
marginTop: '24px',
display: 'flex',
overflowX: 'auto',
+ scrollSnapType: 'x mandatory',
});
export const planCard = style({
@@ -24,11 +30,18 @@ export const planCard = style({
borderRadius: '16px',
padding: '20px',
border: '1px solid var(--affine-border-color)',
+ position: 'relative',
selectors: {
'&:not(:last-child)': {
marginRight: '16px',
},
+ '&::before': {
+ content: '',
+ position: 'absolute',
+ right: 'calc(100% + var(--setting-modal-gap-x))',
+ scrollSnapAlign: 'start',
+ },
},
});
@@ -37,6 +50,7 @@ export const currentPlanCard = style([
{
borderWidth: '2px',
borderColor: 'var(--affine-primary-color)',
+ boxShadow: 'var(--affine-shadow-2)',
},
]);
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..349463a2f9 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);
+
+ useEffect(() => {
+ if (!modalProps.open) return;
+ let animationFrameId: number;
+ const onResize = debounce(() => {
+ cancelAnimationFrame(animationFrameId);
+ animationFrameId = requestAnimationFrame(() => {
+ if (!modalContentRef.current) return;
+
+ const contentWidth = modalContentRef.current.offsetWidth;
+ const computedStyle = window.getComputedStyle(modalContentRef.current);
+ const marginX = parseInt(computedStyle.marginLeft, 10);
+ const paddingX = parseInt(computedStyle.paddingLeft, 10);
+ modalContentRef.current?.style.setProperty(
+ '--setting-modal-width',
+ `${contentWidth + marginX * 2 + paddingX * 2}px`
+ );
+ modalContentRef.current?.style.setProperty(
+ '--setting-modal-gap-x',
+ `${marginX + paddingX}px`
+ );
+ });
+ }, 200);
+ window.addEventListener('resize', onResize);
+ onResize();
+
+ return () => {
+ cancelAnimationFrame(animationFrameId);
+ window.removeEventListener('resize', onResize);
+ };
+ }, [modalProps.open]);
+
const onGeneralSettingClick = useCallback(
(key: GeneralSettingKeys) => {
onSettingClick({
@@ -84,7 +118,11 @@ export const SettingModal = ({
onAccountSettingClick={onAccountSettingClick}
/>
-
+
{activeTab === 'workspace' && workspaceId ? (
}>
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..9ba1ab1c48 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
@@ -6,7 +6,6 @@ export const wrapper = style({
maxWidth: '560px',
margin: '0 auto',
padding: '40px 15px 20px 15px',
- overflow: 'hidden auto',
// children
display: 'flex',
From e8a88da9e4254c426ab4e2ab0f7695eb8c67bc3c Mon Sep 17 00:00:00 2001
From: Cats Juice
Date: Wed, 25 Oct 2023 10:58:19 +0800
Subject: [PATCH 13/27] feat(core): auto scroll to current payment plan (#4714)
---
.../plans/icons/bulled-list.tsx | 19 ++++
.../general-setting/plans/index.tsx | 89 +++++++++++++------
.../general-setting/plans/style.css.ts | 9 ++
3 files changed, 91 insertions(+), 26 deletions(-)
create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/icons/bulled-list.tsx
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
index 0c1e7af33b..1fe7c6ef95 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx
@@ -9,6 +9,7 @@ import {
updateSubscriptionMutation,
} from '@affine/graphql';
import { useMutation, useQuery } from '@affine/workspace/affine/gql';
+import { DoneIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import {
type PropsWithChildren,
@@ -24,6 +25,7 @@ import {
type SubscriptionMutator,
useUserSubscription,
} from '../../../../../hooks/use-subscription';
+import { BulledListIcon } from './icons/bulled-list';
import * as styles from './style.css';
interface FixedPrice {
@@ -108,6 +110,7 @@ const planDetail = new Map([
const Settings = () => {
const [subscription, mutateSubscription] = useUserSubscription();
const loggedIn = useCurrentLoginStatus() === 'authenticated';
+ const scrollWrapper = useRef(null);
const {
data: { prices },
@@ -139,6 +142,32 @@ const Settings = () => {
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]);
+
return (
<>
{
))}
- {/* TODO: plan cards horizontal scroll behavior is not the same as design */}
- {/* TODO: may scroll current plan into view when first loading? */}
-
+
{Array.from(planDetail.values()).map(detail => {
+ const isCurrent =
+ loggedIn &&
+ detail.plan === currentPlan &&
+ (currentPlan === SubscriptionPlan.Free
+ ? true
+ : currentRecurring === recurring);
return (
@@ -198,23 +228,27 @@ const Settings = () => {
)}
-
- {detail.type === 'dynamic' ? (
-
- Coming soon...
-
- ) : (
- <>
-
- $
- {recurring === SubscriptionRecurring.Monthly
- ? detail.price
- : detail.yearlyPrice}
+
+
+ {detail.type === 'dynamic' ? (
+
+ Coming soon...
- per month
- >
- )}
-
+ ) : (
+ <>
+
+ $
+ {recurring === SubscriptionRecurring.Monthly
+ ? detail.price
+ : detail.yearlyPrice}
+
+
+ per month
+
+ >
+ )}
+
+
{
// branches:
// if contact => 'Contact Sales'
@@ -264,8 +298,11 @@ const Settings = () => {
{detail.benefits.map((content, i) => (
- {/* TODO: icons */}
- {detail.type == 'dynamic' ? '·' : '✅'}
+ {detail.type == 'dynamic' ? (
+
+ ) : (
+
+ )}
{content}
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts
index 636169e78e..c3587350d2 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts
@@ -22,6 +22,8 @@ export const planCardsWrapper = style({
display: 'flex',
overflowX: 'auto',
scrollSnapType: 'x mandatory',
+ // TODO: should display the horizontal scrollbar, ensure the box-shadow is not clipped
+ paddingBottom: '21px',
});
export const planCard = style({
@@ -75,6 +77,13 @@ export const planTitle = style({
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',
From eaa90c9fb629a285ba316d93cc645db8ace8e43f Mon Sep 17 00:00:00 2001
From: Cats Juice
Date: Wed, 25 Oct 2023 16:16:50 +0800
Subject: [PATCH 14/27] feat(core): payment plans skeleton (#4715)
---
.../general-setting/plans/index.tsx | 457 ++----------------
.../general-setting/plans/layout.css.ts | 36 ++
.../general-setting/plans/layout.tsx | 52 ++
.../general-setting/plans/plan-card.tsx | 381 +++++++++++++++
.../general-setting/plans/skeleton.css.ts | 29 ++
.../general-setting/plans/skeleton.tsx | 60 +++
.../general-setting/plans/style.css.ts | 43 +-
.../components/affine/setting-modal/index.tsx | 2 +-
8 files changed, 629 insertions(+), 431 deletions(-)
create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.css.ts
create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx
create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx
create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/skeleton.css.ts
create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/skeleton.tsx
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx
index 1fe7c6ef95..6cb38e898c 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx
@@ -1,115 +1,23 @@
import { RadioButton, RadioButtonGroup } from '@affine/component';
-import { SettingHeader } from '@affine/component/setting-components';
import {
- cancelSubscriptionMutation,
- checkoutMutation,
pricesQuery,
SubscriptionPlan,
SubscriptionRecurring,
- updateSubscriptionMutation,
} from '@affine/graphql';
-import { useMutation, useQuery } from '@affine/workspace/affine/gql';
-import { DoneIcon } from '@blocksuite/icons';
-import { Button } from '@toeverything/components/button';
-import {
- type PropsWithChildren,
- Suspense,
- useCallback,
- useEffect,
- useRef,
- useState,
-} from 'react';
+import { useQuery } from '@affine/workspace/affine/gql';
+import { Suspense, useEffect, useRef, useState } from 'react';
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
-import {
- type SubscriptionMutator,
- useUserSubscription,
-} from '../../../../../hooks/use-subscription';
-import { BulledListIcon } from './icons/bulled-list';
+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';
-interface FixedPrice {
- type: 'fixed';
- plan: SubscriptionPlan;
- price: string;
- yearlyPrice: string;
- discount?: string;
- benefits: string[];
-}
-
-interface DynamicPrice {
- type: 'dynamic';
- plan: SubscriptionPlan;
- contact: boolean;
- benefits: string[];
-}
-
-// TODO: i18n all things
-const planDetail = new Map([
- [
- SubscriptionPlan.Free,
- {
- type: 'fixed',
- plan: SubscriptionPlan.Free,
- price: '0',
- yearlyPrice: '0',
- benefits: [
- 'Unlimited local workspace',
- 'Unlimited login devices',
- 'Unlimited blocks',
- 'AFFiNE Cloud Storage 10GB',
- 'The maximum file size is 10M',
- 'Number of members per Workspace ≤ 3',
- ],
- },
- ],
- [
- SubscriptionPlan.Pro,
- {
- type: 'fixed',
- plan: SubscriptionPlan.Pro,
- price: '1',
- yearlyPrice: '1',
- benefits: [
- 'Unlimited local workspace',
- 'Unlimited login devices',
- 'Unlimited blocks',
- 'AFFiNE Cloud Storage 100GB',
- 'The maximum file size is 500M',
- 'Number of members per Workspace ≤ 10',
- ],
- },
- ],
- [
- SubscriptionPlan.Team,
- {
- type: 'dynamic',
- plan: SubscriptionPlan.Team,
- contact: true,
- benefits: [
- 'Best team workspace for collaboration and knowledge distilling.',
- 'Focusing on what really matters with team project management and automation.',
- 'Pay for seats, fits all team size.',
- ],
- },
- ],
- [
- SubscriptionPlan.Enterprise,
- {
- type: 'dynamic',
- plan: SubscriptionPlan.Enterprise,
- contact: true,
- benefits: [
- 'Solutions & best practices for dedicated needs.',
- 'Embedable & interrogations with IT support.',
- ],
- },
- ],
-]);
-
const Settings = () => {
const [subscription, mutateSubscription] = useUserSubscription();
const loggedIn = useCurrentLoginStatus() === 'authenticated';
+ const planDetail = getPlanDetail();
const scrollWrapper = useRef(null);
const {
@@ -136,7 +44,6 @@ const Settings = () => {
);
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
- const currentRecurring = subscription?.recurring;
const yearlyDiscount = (
planDetail.get(SubscriptionPlan.Pro) as FixedPrice | undefined
@@ -168,324 +75,66 @@ const Settings = () => {
};
}, [recurring]);
- return (
- <>
-
- You are current on the {currentPlan} plan. If you have any
- questions, please contact our{' '}
- {/*TODO: add action*/}customer support .
-
- ) : (
-
- This is the Pricing plans of AFFiNE Cloud. You can sign up or sign
- in to your account first.
-
- )
- }
- />
-
-
- {Object.values(SubscriptionRecurring).map(plan => (
-
- {plan}
- {plan === SubscriptionRecurring.Yearly && yearlyDiscount && (
-
- {yearlyDiscount}% off
-
- )}
-
- ))}
-
-
- {Array.from(planDetail.values()).map(detail => {
- const isCurrent =
- loggedIn &&
- detail.plan === currentPlan &&
- (currentPlan === SubscriptionPlan.Free
- ? true
- : currentRecurring === recurring);
- return (
-
-
-
- {detail.plan}{' '}
- {'discount' in detail &&
- recurring === SubscriptionRecurring.Yearly && (
-
- {detail.discount}% off
-
- )}
-
-
-
- {detail.type === 'dynamic' ? (
-
- Coming soon...
-
- ) : (
- <>
-
- $
- {recurring === SubscriptionRecurring.Monthly
- ? detail.price
- : detail.yearlyPrice}
-
-
- per month
-
- >
- )}
-
-
- {
- // branches:
- // if contact => 'Contact Sales'
- // if not signed in:
- // if free => 'Sign up free'
- // else => 'Buy Pro'
- // else
- // if isCurrent => 'Current Plan'
- // else if free => 'Downgrade'
- // else if currentRecurring !== recurring => 'Change to {recurring} Billing'
- // else => 'Upgrade'
- // TODO: should replace with components with proper actions
- detail.type === 'dynamic' ? (
-
- ) : loggedIn ? (
- detail.plan === currentPlan &&
- (currentRecurring === recurring ||
- (!currentRecurring &&
- detail.plan === SubscriptionPlan.Free)) ? (
-
- ) : detail.plan === SubscriptionPlan.Free ? (
-
- ) : currentRecurring !== recurring &&
- currentPlan === detail.plan ? (
-
- ) : (
-
- )
- ) : (
-
- {detail.plan === SubscriptionPlan.Free
- ? 'Sign up free'
- : 'Buy Pro'}
-
- )
- }
-
-
- {detail.benefits.map((content, i) => (
-
-
- {detail.type == 'dynamic' ? (
-
- ) : (
-
- )}
-
- {content}
-
- ))}
-
-
- );
- })}
-
-
- See all plans →{/* TODO: icon */}
-
-
- >
+ const subtitle = loggedIn ? (
+
+ You are current on the {currentPlan} plan. If you have any questions,
+ please contact our {/*TODO: add action*/}customer support .
+
+ ) : (
+
+ This is the Pricing plans of AFFiNE Cloud. You can sign up or sign in to
+ your account first.
+
);
-};
-const Downgrade = ({
- onSubscriptionUpdate,
-}: {
- onSubscriptionUpdate: SubscriptionMutator;
-}) => {
- const { isMutating, trigger } = useMutation({
- mutation: cancelSubscriptionMutation,
- });
+ const getRecurringLabel = (recurring: SubscriptionRecurring) =>
+ ({
+ [SubscriptionRecurring.Monthly]: 'Monthly',
+ [SubscriptionRecurring.Yearly]: 'Annually',
+ })[recurring];
- const downgrade = useCallback(() => {
- trigger(null, {
- onSuccess: data => {
- onSubscriptionUpdate(data.cancelSubscription);
- },
- });
- }, [trigger, onSubscriptionUpdate]);
-
- return (
-
- Downgrade
-
+ {Object.values(SubscriptionRecurring).map(recurring => (
+
+ {getRecurringLabel(recurring)}
+ {recurring === SubscriptionRecurring.Yearly && yearlyDiscount && (
+
+ {yearlyDiscount}% off
+
+ )}
+
+ ))}
+
);
-};
-const Upgrade = ({
- recurring,
- onSubscriptionUpdate,
-}: {
- recurring: SubscriptionRecurring;
- onSubscriptionUpdate: SubscriptionMutator;
-}) => {
- const { isMutating, trigger } = useMutation({
- mutation: checkoutMutation,
- });
-
- const newTabRef = useRef
(null);
-
- const onClose = useCallback(() => {
- newTabRef.current = null;
- onSubscriptionUpdate();
- }, [onSubscriptionUpdate]);
-
- const upgrade = useCallback(() => {
- if (newTabRef.current) {
- newTabRef.current.focus();
- } else {
- trigger(
- { recurring },
- {
- onSuccess: data => {
- // FIXME: safari prevents from opening new tab by window api
- // TODO(@xp): what if electron?
- const newTab = window.open(
- data.checkout,
- '_blank',
- 'noopener noreferrer'
- );
-
- if (newTab) {
- newTabRef.current = newTab;
-
- newTab.addEventListener('close', onClose);
- }
- },
- }
- );
- }
- }, [trigger, recurring, onClose]);
-
- useEffect(() => {
- return () => {
- if (newTabRef.current) {
- newTabRef.current.removeEventListener('close', onClose);
- newTabRef.current = null;
- }
- };
- }, [onClose]);
+ const scroll = (
+
+ {Array.from(planDetail.values()).map(detail => {
+ return (
+
+ );
+ })}
+
+ );
return (
-
- Upgrade
-
- );
-};
-
-const ChangeRecurring = ({
- from: _from /* TODO: from can be useful when showing confirmation modal */,
- to,
- onSubscriptionUpdate,
-}: {
- from: SubscriptionRecurring;
- to: SubscriptionRecurring;
- onSubscriptionUpdate: SubscriptionMutator;
-}) => {
- const { isMutating, trigger } = useMutation({
- mutation: updateSubscriptionMutation,
- });
-
- const change = useCallback(() => {
- trigger(
- { recurring: to },
- {
- onSuccess: data => {
- onSubscriptionUpdate(data.updateSubscriptionRecurring);
- },
- }
- );
- }, [trigger, onSubscriptionUpdate, to]);
-
- return (
-
- Change to {to} Billing
-
- );
-};
-
-const ContactSales = () => {
- return (
- // TODO: add action
-
- Contact Sales
-
- );
-};
-
-const CurrentPlan = () => {
- return Current Plan ;
-};
-
-const SignupAction = ({ children }: PropsWithChildren) => {
- // TODO: add login action
- return (
-
- {children}
-
+
);
};
export const AFFiNECloudPlans = () => {
return (
- // TODO: loading skeleton
// TODO: Error Boundary
-
+ }>
);
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.css.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.css.ts
new file mode 100644
index 0000000000..72ab6ab5b0
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.css.ts
@@ -0,0 +1,36 @@
+import { style } from '@vanilla-extract/css';
+
+export const plansLayoutRoot = style({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '24px',
+});
+
+export const scrollArea = style({
+ marginLeft: 'calc(-1 * var(--setting-modal-gap-x))',
+ paddingLeft: 'var(--setting-modal-gap-x)',
+ width: 'var(--setting-modal-width)',
+ overflowX: 'auto',
+ scrollSnapType: 'x mandatory',
+ paddingBottom: '21px',
+
+ '::-webkit-scrollbar': {
+ display: 'block',
+ height: '5px',
+ background: 'transparent',
+ },
+ '::-webkit-scrollbar-thumb': {
+ background: 'var(--affine-icon-secondary)',
+ borderRadius: '5px',
+ },
+});
+
+export const allPlansLink = style({
+ display: 'flex',
+ alignItems: 'center',
+ gap: '4px',
+ color: 'var(--affine-link-color)',
+ background: 'transparent',
+ borderColor: 'transparent',
+ fontSize: 'var(--affine-font-xs)',
+});
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx
new file mode 100644
index 0000000000..424e944cd0
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx
@@ -0,0 +1,52 @@
+import { SettingHeader } from '@affine/component/setting-components';
+import { ArrowRightBigIcon } from '@blocksuite/icons';
+import type { HtmlHTMLAttributes, ReactNode } from 'react';
+
+import * as styles from './layout.css';
+
+export interface PlanLayoutProps
+ extends Omit, 'title'> {
+ title?: ReactNode;
+ subtitle: ReactNode;
+ tabs: ReactNode;
+ scroll: ReactNode;
+ footer?: ReactNode;
+ scrollRef?: React.RefObject;
+}
+
+const SeeAllLink = () => (
+
+ See all plans
+ { }
+
+);
+
+export const PlanLayout = ({
+ subtitle,
+ tabs,
+ scroll,
+ title = 'Pricing Plans',
+ footer = ,
+ scrollRef,
+}: PlanLayoutProps) => {
+ return (
+
+ {/* TODO: SettingHeader component shouldn't have margin itself */}
+
+ {tabs}
+
+ {scroll}
+
+ {footer}
+
+ );
+};
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx
new file mode 100644
index 0000000000..6dd027fe79
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx
@@ -0,0 +1,381 @@
+import type {
+ Subscription,
+ SubscriptionMutator,
+} from '@affine/core/hooks/use-subscription';
+import {
+ cancelSubscriptionMutation,
+ checkoutMutation,
+ SubscriptionPlan,
+ SubscriptionRecurring,
+ updateSubscriptionMutation,
+} from '@affine/graphql';
+import { useMutation } from '@affine/workspace/affine/gql';
+import { DoneIcon } from '@blocksuite/icons';
+import { Button } from '@toeverything/components/button';
+import { type PropsWithChildren, useCallback, useEffect, useRef } from 'react';
+
+import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
+import { BulledListIcon } from './icons/bulled-list';
+import * as styles from './style.css';
+
+export interface FixedPrice {
+ type: 'fixed';
+ plan: SubscriptionPlan;
+ price: string;
+ yearlyPrice: string;
+ discount?: string;
+ benefits: string[];
+}
+
+export interface DynamicPrice {
+ type: 'dynamic';
+ plan: SubscriptionPlan;
+ contact: boolean;
+ benefits: string[];
+}
+
+interface PlanCardProps {
+ detail: FixedPrice | DynamicPrice;
+ subscription?: Subscription | null;
+ recurring: string;
+ onSubscriptionUpdate: SubscriptionMutator;
+}
+
+export function getPlanDetail() {
+ // const t = useAFFiNEI18N();
+
+ // TODO: i18n all things
+ return new Map([
+ [
+ SubscriptionPlan.Free,
+ {
+ type: 'fixed',
+ plan: SubscriptionPlan.Free,
+ price: '0',
+ yearlyPrice: '0',
+ benefits: [
+ 'Unlimited local workspace',
+ 'Unlimited login devices',
+ 'Unlimited blocks',
+ 'AFFiNE Cloud Storage 10GB',
+ 'The maximum file size is 10M',
+ 'Number of members per Workspace ≤ 3',
+ ],
+ },
+ ],
+ [
+ SubscriptionPlan.Pro,
+ {
+ type: 'fixed',
+ plan: SubscriptionPlan.Pro,
+ price: '1',
+ yearlyPrice: '1',
+ benefits: [
+ 'Unlimited local workspace',
+ 'Unlimited login devices',
+ 'Unlimited blocks',
+ 'AFFiNE Cloud Storage 100GB',
+ 'The maximum file size is 500M',
+ 'Number of members per Workspace ≤ 10',
+ ],
+ },
+ ],
+ [
+ SubscriptionPlan.Team,
+ {
+ type: 'dynamic',
+ plan: SubscriptionPlan.Team,
+ contact: true,
+ benefits: [
+ 'Best team workspace for collaboration and knowledge distilling.',
+ 'Focusing on what really matters with team project management and automation.',
+ 'Pay for seats, fits all team size.',
+ ],
+ },
+ ],
+ [
+ SubscriptionPlan.Enterprise,
+ {
+ type: 'dynamic',
+ plan: SubscriptionPlan.Enterprise,
+ contact: true,
+ benefits: [
+ 'Solutions & best practices for dedicated needs.',
+ 'Embedable & interrogations with IT support.',
+ ],
+ },
+ ],
+ ]);
+}
+
+export const PlanCard = ({
+ detail,
+ subscription,
+ recurring,
+ onSubscriptionUpdate,
+}: PlanCardProps) => {
+ const loggedIn = useCurrentLoginStatus() === 'authenticated';
+ const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
+ const currentRecurring = subscription?.recurring;
+
+ const isCurrent =
+ loggedIn &&
+ detail.plan === currentPlan &&
+ (currentPlan === SubscriptionPlan.Free
+ ? true
+ : currentRecurring === recurring);
+
+ return (
+
+
+
+ {detail.plan}{' '}
+ {'discount' in detail &&
+ recurring === SubscriptionRecurring.Yearly && (
+
+ {detail.discount}% off
+
+ )}
+
+
+
+ {detail.type === 'dynamic' ? (
+ Coming soon...
+ ) : (
+ <>
+
+ $
+ {recurring === SubscriptionRecurring.Monthly
+ ? detail.price
+ : detail.yearlyPrice}
+
+ per month
+ >
+ )}
+
+
+ {
+ // branches:
+ // if contact => 'Contact Sales'
+ // if not signed in:
+ // if free => 'Sign up free'
+ // else => 'Buy Pro'
+ // else
+ // if isCurrent => 'Current Plan'
+ // else if free => 'Downgrade'
+ // else if currentRecurring !== recurring => 'Change to {recurring} Billing'
+ // else => 'Upgrade'
+ // TODO: should replace with components with proper actions
+ detail.type === 'dynamic' ? (
+
+ ) : loggedIn ? (
+ detail.plan === currentPlan &&
+ (currentRecurring === recurring ||
+ (!currentRecurring && detail.plan === SubscriptionPlan.Free)) ? (
+
+ ) : detail.plan === SubscriptionPlan.Free ? (
+
+ ) : currentRecurring !== recurring &&
+ currentPlan === detail.plan ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ {detail.plan === SubscriptionPlan.Free
+ ? 'Sign up free'
+ : 'Buy Pro'}
+
+ )
+ }
+
+
+ {detail.benefits.map((content, i) => (
+
+
+ {detail.type == 'dynamic' ? (
+
+ ) : (
+
+ )}
+
+
{content}
+
+ ))}
+
+
+ );
+};
+
+const CurrentPlan = () => {
+ return Current Plan ;
+};
+
+const Downgrade = ({
+ onSubscriptionUpdate,
+}: {
+ onSubscriptionUpdate: SubscriptionMutator;
+}) => {
+ const { isMutating, trigger } = useMutation({
+ mutation: cancelSubscriptionMutation,
+ });
+
+ const downgrade = useCallback(() => {
+ trigger(null, {
+ onSuccess: data => {
+ onSubscriptionUpdate(data.cancelSubscription);
+ },
+ });
+ }, [trigger, onSubscriptionUpdate]);
+
+ return (
+
+ Downgrade
+
+ );
+};
+
+const ContactSales = () => {
+ return (
+ // TODO: add action
+
+ Contact Sales
+
+ );
+};
+
+const Upgrade = ({
+ recurring,
+ onSubscriptionUpdate,
+}: {
+ recurring: SubscriptionRecurring;
+ onSubscriptionUpdate: SubscriptionMutator;
+}) => {
+ const { isMutating, trigger } = useMutation({
+ mutation: checkoutMutation,
+ });
+
+ const newTabRef = useRef(null);
+
+ const onClose = useCallback(() => {
+ newTabRef.current = null;
+ onSubscriptionUpdate();
+ }, [onSubscriptionUpdate]);
+
+ const upgrade = useCallback(() => {
+ if (newTabRef.current) {
+ newTabRef.current.focus();
+ } else {
+ trigger(
+ { recurring },
+ {
+ onSuccess: data => {
+ // FIXME: safari prevents from opening new tab by window api
+ // TODO(@xp): what if electron?
+ const newTab = window.open(
+ data.checkout,
+ '_blank',
+ 'noopener noreferrer'
+ );
+
+ if (newTab) {
+ newTabRef.current = newTab;
+
+ newTab.addEventListener('close', onClose);
+ }
+ },
+ }
+ );
+ }
+ }, [trigger, recurring, onClose]);
+
+ useEffect(() => {
+ return () => {
+ if (newTabRef.current) {
+ newTabRef.current.removeEventListener('close', onClose);
+ newTabRef.current = null;
+ }
+ };
+ }, [onClose]);
+
+ return (
+
+ Upgrade
+
+ );
+};
+
+const ChangeRecurring = ({
+ from: _from /* TODO: from can be useful when showing confirmation modal */,
+ to,
+ onSubscriptionUpdate,
+}: {
+ from: SubscriptionRecurring;
+ to: SubscriptionRecurring;
+ onSubscriptionUpdate: SubscriptionMutator;
+}) => {
+ const { isMutating, trigger } = useMutation({
+ mutation: updateSubscriptionMutation,
+ });
+
+ const change = useCallback(() => {
+ trigger(
+ { recurring: to },
+ {
+ onSuccess: data => {
+ onSubscriptionUpdate(data.updateSubscriptionRecurring);
+ },
+ }
+ );
+ }, [trigger, onSubscriptionUpdate, to]);
+
+ return (
+
+ Change to {to} Billing
+
+ );
+};
+
+const SignupAction = ({ children }: PropsWithChildren) => {
+ // TODO: add login action
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/skeleton.css.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/skeleton.css.ts
new file mode 100644
index 0000000000..abec1785a6
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/skeleton.css.ts
@@ -0,0 +1,29 @@
+import { style } from '@vanilla-extract/css';
+
+export const plansWrapper = style({
+ display: 'flex',
+ gap: '16px',
+});
+
+export const planItemCard = style({
+ width: '258px',
+ height: '426px',
+ flexShrink: '0',
+ borderRadius: '16px',
+ backgroundColor: 'var(--affine-background-primary-color)',
+ border: '1px solid var(--affine-border-color)',
+ padding: '20px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '20px',
+});
+
+export const planItemHeader = style({
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '10px',
+});
+export const planItemContent = style({
+ flexGrow: '1',
+ height: 0,
+});
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/skeleton.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/skeleton.tsx
new file mode 100644
index 0000000000..1ae9a390b7
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/skeleton.tsx
@@ -0,0 +1,60 @@
+import { Skeleton } from '@mui/material';
+
+import { PlanLayout } from './layout';
+import * as styles from './skeleton.css';
+
+/**
+ * Customize Skeleton component with rounded border radius
+ * @param param0
+ * @returns
+ */
+const RoundedSkeleton = ({
+ radius = 8,
+ ...props
+}: {
+ radius?: number;
+} & React.ComponentProps) => (
+
+);
+
+const SubtitleSkeleton = () => (
+
+);
+
+const TabsSkeleton = () => (
+ // TODO: height should be `32px` by design
+ // but the RadioGroup component is not matching with the design currently
+ // set to `24px` for now to avoid blinking
+
+);
+
+const PlanItemSkeleton = () => (
+
+
+
+
+
+
+
+);
+
+const ScrollSkeleton = () => (
+
+);
+
+export const PlansSkeleton = () => {
+ return (
+ }
+ tabs={ }
+ scroll={ }
+ />
+ );
+};
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts
index c3587350d2..4189024ff9 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts
@@ -14,16 +14,10 @@ export const radioButtonDiscount = style({
});
export const planCardsWrapper = style({
- marginLeft: 'calc(-1 * var(--setting-modal-gap-x))',
- paddingLeft: 'var(--setting-modal-gap-x)',
- paddingRight: 'calc(var(--setting-modal-gap-x) + 300px)',
- width: 'var(--setting-modal-width)',
- marginTop: '24px',
+ paddingRight: 'calc(var(--setting-modal-gap-x))',
display: 'flex',
- overflowX: 'auto',
- scrollSnapType: 'x mandatory',
- // TODO: should display the horizontal scrollbar, ensure the box-shadow is not clipped
- paddingBottom: '21px',
+ gap: '16px',
+ width: 'fit-content',
});
export const planCard = style({
@@ -35,9 +29,6 @@ export const planCard = style({
position: 'relative',
selectors: {
- '&:not(:last-child)': {
- marginRight: '16px',
- },
'&::before': {
content: '',
position: 'absolute',
@@ -101,27 +92,27 @@ export const planAction = style({
export const planBenefits = style({
marginTop: '20px',
fontSize: 'var(--affine-font-xs)',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '8px',
});
export const planBenefit = style({
display: 'flex',
- selectors: {
- '&:not(:last-child)': {
- marginBottom: '8px',
- },
- },
+ gap: '8px',
+ lineHeight: '20px',
+ alignItems: 'normal',
+ fontSize: '12px',
});
export const planBenefitIcon = style({
- display: 'inline-block',
- marginRight: '8px',
+ display: 'flex',
+ alignItems: 'center',
+ height: '20px',
});
-export const allPlansLink = style({
- display: 'block',
- marginTop: '36px',
- color: 'var(--affine-primary-color)',
- background: 'transparent',
- borderColor: 'transparent',
- fontSize: 'var(--affine-font-xs)',
+export const planBenefitText = style({
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
});
diff --git a/packages/frontend/core/src/components/affine/setting-modal/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/index.tsx
index 349463a2f9..dbac104bd7 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/index.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/index.tsx
@@ -54,7 +54,7 @@ export const SettingModal = ({
const paddingX = parseInt(computedStyle.paddingLeft, 10);
modalContentRef.current?.style.setProperty(
'--setting-modal-width',
- `${contentWidth + marginX * 2 + paddingX * 2}px`
+ `${contentWidth + marginX * 2}px`
);
modalContentRef.current?.style.setProperty(
'--setting-modal-gap-x',
From df69c908fea4d8612fc55ab1af75afb9577ea0fa Mon Sep 17 00:00:00 2001
From: JimmFly
Date: Wed, 25 Oct 2023 16:18:30 +0800
Subject: [PATCH 15/27] feat(core): adapt storage progress to payment system
(#4713)
---
packages/frontend/component/package.json | 2 +
.../setting-components/share.css.ts | 20 +---
.../setting-components/storage-progess.tsx | 97 +++++++++----------
packages/frontend/core/package.json | 2 +
.../setting-modal/account-setting/index.tsx | 33 ++++++-
.../account-setting/style.css.ts | 4 +
yarn.lock | 13 ++-
7 files changed, 99 insertions(+), 72 deletions(-)
diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json
index dcd37e6b1f..bf1d58bc68 100644
--- a/packages/frontend/component/package.json
+++ b/packages/frontend/component/package.json
@@ -40,6 +40,7 @@
"@toeverything/infra": "workspace:*",
"@toeverything/theme": "^0.7.20",
"@vanilla-extract/dynamic": "^2.0.3",
+ "bytes": "^3.1.2",
"check-password-strength": "^2.0.7",
"clsx": "^2.0.0",
"dayjs": "^1.11.10",
@@ -71,6 +72,7 @@
"@storybook/jest": "^0.2.3",
"@storybook/testing-library": "^0.2.2",
"@testing-library/react": "^14.0.0",
+ "@types/bytes": "^3.1.3",
"@types/react": "^18.2.28",
"@types/react-datepicker": "^4.19.0",
"@types/react-dnd": "^3.0.2",
diff --git a/packages/frontend/component/src/components/setting-components/share.css.ts b/packages/frontend/component/src/components/setting-components/share.css.ts
index c5fffdc144..be26abbf75 100644
--- a/packages/frontend/component/src/components/setting-components/share.css.ts
+++ b/packages/frontend/component/src/components/setting-components/share.css.ts
@@ -115,31 +115,19 @@ globalStyle(`${storageProgressWrapper} .storage-progress-desc`, {
globalStyle(`${storageProgressWrapper} .storage-progress-bar-wrapper`, {
height: '8px',
borderRadius: '4px',
- backgroundColor: 'var(--affine-pure-black-10)',
+ backgroundColor: 'var(--affine-black-10)',
overflow: 'hidden',
});
export const storageProgressBar = style({
height: '100%',
backgroundColor: 'var(--affine-processing-color)',
selectors: {
- '&.warning': {
- // Wait for design
- backgroundColor: '#FF7C09',
- },
'&.danger': {
backgroundColor: 'var(--affine-error-color)',
},
},
});
-export const storageExtendHint = style({
- borderRadius: '4px',
- padding: '4px 8px',
- backgroundColor: 'var(--affine-background-secondary-color)',
- color: 'var(--affine-text-secondary-color)',
- fontSize: 'var(--affine-font-xs)',
- lineHeight: '20px',
- marginTop: 8,
-});
-globalStyle(`${storageExtendHint} a`, {
- color: 'var(--affine-link-color)',
+
+export const storageButton = style({
+ padding: '4px 12px',
});
diff --git a/packages/frontend/component/src/components/setting-components/storage-progess.tsx b/packages/frontend/component/src/components/setting-components/storage-progess.tsx
index 5968bf29cd..33dd170182 100644
--- a/packages/frontend/component/src/components/setting-components/storage-progess.tsx
+++ b/packages/frontend/component/src/components/setting-components/storage-progess.tsx
@@ -1,6 +1,7 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
+import bytes from 'bytes';
import clsx from 'clsx';
import { useMemo } from 'react';
@@ -10,20 +11,14 @@ export interface StorageProgressProgress {
max: number;
value: number;
onUpgrade: () => void;
+ plan: string;
}
-const transformBytesToMB = (bytes: number) => {
- return (bytes / 1024 / 1024).toFixed(2);
-};
-
-const transformBytesToGB = (bytes: number) => {
- return (bytes / 1024 / 1024 / 1024).toFixed(2);
-};
-
export const StorageProgress = ({
max: upperLimit,
value,
onUpgrade,
+ plan,
}: StorageProgressProgress) => {
const t = useAFFiNEI18N();
const percent = useMemo(
@@ -31,51 +26,53 @@ export const StorageProgress = ({
[upperLimit, value]
);
- const used = useMemo(() => transformBytesToMB(value), [value]);
- const max = useMemo(() => transformBytesToGB(upperLimit), [upperLimit]);
+ const used = useMemo(() => bytes.format(value), [value]);
+ const max = useMemo(() => bytes.format(upperLimit), [upperLimit]);
+
+ const buttonType = useMemo(() => {
+ if (plan === 'Free') {
+ return 'primary';
+ }
+ return 'default';
+ }, [plan]);
return (
- <>
-
-
-
- {t['com.affine.storage.used.hint']()}
-
- {used}MB/{max}GB
-
-
-
-
-
80,
- danger: percent > 99,
- })}
- style={{ width: `${percent}%` }}
- >
-
-
-
-
-
-
- {t['com.affine.storage.upgrade']()}
-
+
+
+
+ {t['com.affine.storage.used.hint']()}
+
+ {used}/{max}
+ {` (${plan} Plan)`}
-
-
- {percent > 80 ? (
-
- ) : null}
- >
+
+
+
80,
+ })}
+ style={{ width: `${percent > 100 ? '100' : percent}%` }}
+ >
+
+
+
+
+
+
+ {plan === 'Free' ? 'Upgrade' : 'Change'}
+
+
+
+
);
};
diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json
index 2438b74f82..a7d00eaa06 100644
--- a/packages/frontend/core/package.json
+++ b/packages/frontend/core/package.json
@@ -43,6 +43,7 @@
"@react-hookz/web": "^23.1.0",
"@toeverything/components": "^0.0.45",
"async-call-rpc": "^6.3.1",
+ "bytes": "^3.1.2",
"css-spring": "^4.1.0",
"cssnano": "^6.0.1",
"graphql": "^16.8.1",
@@ -75,6 +76,7 @@
"@sentry/webpack-plugin": "^2.8.0",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.3.93",
+ "@types/bytes": "^3.1.3",
"@types/lodash-es": "^4.17.9",
"@types/webpack-env": "^1.18.2",
"copy-webpack-plugin": "^11.0.0",
diff --git a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx
index b58fe74b57..8a5541b1a0 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx
@@ -7,6 +7,7 @@ import {
import {
allBlobSizesQuery,
removeAvatarMutation,
+ SubscriptionPlan,
uploadAvatarMutation,
} from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
@@ -14,17 +15,24 @@ import { useMutation, useQuery } from '@affine/workspace/affine/gql';
import { ArrowRightSmallIcon, CameraIcon } from '@blocksuite/icons';
import { Avatar } from '@toeverything/components/avatar';
import { Button } from '@toeverything/components/button';
+import bytes from 'bytes';
import { useSetAtom } from 'jotai';
import {
type FC,
type MouseEvent,
Suspense,
useCallback,
+ useMemo,
useState,
} from 'react';
-import { authAtom, openSignOutModalAtom } from '../../../../atoms';
+import {
+ authAtom,
+ openSettingModalAtom,
+ openSignOutModalAtom,
+} from '../../../../atoms';
import { useCurrentUser } from '../../../../hooks/affine/use-current-user';
+import { useUserSubscription } from '../../../../hooks/use-subscription';
import { Upload } from '../../../pure/file-upload';
import * as style from './style.css';
@@ -124,6 +132,7 @@ export const AvatarAndName = () => {
{
query: allBlobSizesQuery,
});
- const onUpgrade = useCallback(() => {}, []);
+ const [subscription] = useUserSubscription();
+ const plan = subscription?.plan ?? SubscriptionPlan.Free;
+ const maxLimit = useMemo(() => {
+ return bytes.parse(plan === SubscriptionPlan.Free ? '10GB' : '100GB');
+ }, [plan]);
+
+ const setSettingModalAtom = useSetAtom(openSettingModalAtom);
+ const onUpgrade = useCallback(() => {
+ setSettingModalAtom({
+ open: true,
+ activeTab: 'plans',
+ workspaceId: null,
+ });
+ }, [setSettingModalAtom]);
return (
{
spreadCol={false}
>
@@ -200,7 +223,7 @@ export const AccountSetting: FC = () => {
/>
-
+
{t['com.affine.settings.email.action']()}
@@ -208,7 +231,7 @@ export const AccountSetting: FC = () => {
name={t['com.affine.settings.password']()}
desc={t['com.affine.settings.password.message']()}
>
-
+
{user.hasPassword
? t['com.affine.settings.password.action.change']()
: t['com.affine.settings.password.action.set']()}
diff --git a/packages/frontend/core/src/components/affine/setting-modal/account-setting/style.css.ts b/packages/frontend/core/src/components/affine/setting-modal/account-setting/style.css.ts
index 369371e380..87648a1dc2 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/account-setting/style.css.ts
+++ b/packages/frontend/core/src/components/affine/setting-modal/account-setting/style.css.ts
@@ -39,3 +39,7 @@ globalStyle(`${avatarWrapper} .camera-icon-wrapper`, {
color: 'var(--affine-white)',
fontSize: 'var(--affine-font-h-4)',
});
+
+export const button = style({
+ padding: '4px 12px',
+});
diff --git a/yarn.lock b/yarn.lock
index 40dfb5747c..f3d138a645 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -223,12 +223,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"
@@ -327,9 +329,11 @@ __metadata:
"@svgr/webpack": "npm:^8.1.0"
"@swc/core": "npm:^1.3.93"
"@toeverything/components": "npm:^0.0.45"
+ "@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"
@@ -12856,6 +12860,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"
@@ -16310,7 +16321,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
From 1deb6bffd377f238b8b30a39df7c215f09a31945 Mon Sep 17 00:00:00 2001
From: Joooye_34
Date: Thu, 26 Oct 2023 00:50:39 +0800
Subject: [PATCH 16/27] feat(core): disable payment in canary (#4722)
---
packages/common/env/src/global.ts | 1 +
.../frontend/core/.webpack/runtime-config.ts | 7 ++++
packages/frontend/core/package.json | 2 +-
packages/frontend/core/src/atoms/index.ts | 1 +
.../affine/payment-disable/index.tsx | 35 +++++++++++++++++++
.../affine/payment-disable/style.css.ts | 5 +++
.../general-setting/plans/plan-card.tsx | 10 +++++-
.../core/src/providers/modal-provider.tsx | 2 ++
packages/frontend/i18n/src/resources/en.json | 2 ++
packages/plugins/copilot/package.json | 2 +-
packages/plugins/hello-world/package.json | 2 +-
packages/plugins/image-preview/package.json | 2 +-
packages/plugins/outline/package.json | 2 +-
yarn.lock | 18 +++++-----
14 files changed, 76 insertions(+), 15 deletions(-)
create mode 100644 packages/frontend/core/src/components/affine/payment-disable/index.tsx
create mode 100644 packages/frontend/core/src/components/affine/payment-disable/style.css.ts
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/core/.webpack/runtime-config.ts b/packages/frontend/core/.webpack/runtime-config.ts
index 109e93c059..a93c354394 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: false,
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 a7d00eaa06..9fe5dd3dba 100644
--- a/packages/frontend/core/package.json
+++ b/packages/frontend/core/package.json
@@ -41,7 +41,7 @@
"@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",
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/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/general-setting/plans/plan-card.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx
index 6dd027fe79..45c90ab68b 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx
@@ -12,8 +12,10 @@ import {
import { useMutation } from '@affine/workspace/affine/gql';
import { DoneIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
+import { useAtom } from 'jotai';
import { type PropsWithChildren, useCallback, useEffect, useRef } from 'react';
+import { openPaymentDisableAtom } from '../../../../../atoms';
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
import { BulledListIcon } from './icons/bulled-list';
import * as styles from './style.css';
@@ -285,7 +287,13 @@ const Upgrade = ({
onSubscriptionUpdate();
}, [onSubscriptionUpdate]);
+ const [, openPaymentDisableModal] = useAtom(openPaymentDisableAtom);
const upgrade = useCallback(() => {
+ if (!runtimeConfig.enablePayment) {
+ openPaymentDisableModal(true);
+ return;
+ }
+
if (newTabRef.current) {
newTabRef.current.focus();
} else {
@@ -310,7 +318,7 @@ const Upgrade = ({
}
);
}
- }, [trigger, recurring, onClose]);
+ }, [trigger, recurring, onClose, openPaymentDisableModal]);
useEffect(() => {
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/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json
index 3889d7a5b0..2cc521d8e2 100644
--- a/packages/frontend/i18n/src/resources/en.json
+++ b/packages/frontend/i18n/src/resources/en.json
@@ -629,6 +629,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",
diff --git a/packages/plugins/copilot/package.json b/packages/plugins/copilot/package.json
index 111af9f5a6..6418a52ad7 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.34",
- "@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 bce579aafa..e0529124ce 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.34",
- "@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 64e1fd8c3a..3f8a5b2405 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.34",
- "@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 6e0deb22e5..4bf4e51528 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.34",
- "@toeverything/components": "^0.0.45"
+ "@toeverything/components": "^0.0.46"
},
"devDependencies": {
"@affine/plugin-cli": "workspace:*",
diff --git a/yarn.lock b/yarn.lock
index f3d138a645..43dd0fd792 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -274,7 +274,7 @@ __metadata:
"@affine/plugin-cli": "workspace:*"
"@affine/sdk": "workspace:*"
"@blocksuite/icons": "npm:2.1.34"
- "@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"
@@ -328,7 +328,7 @@ __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"
@@ -490,7 +490,7 @@ __metadata:
"@affine/plugin-cli": "workspace:*"
"@affine/sdk": "workspace:*"
"@blocksuite/icons": "npm:2.1.34"
- "@toeverything/components": "npm:^0.0.45"
+ "@toeverything/components": "npm:^0.0.46"
languageName: unknown
linkType: soft
@@ -516,7 +516,7 @@ __metadata:
"@affine/plugin-cli": "workspace:*"
"@affine/sdk": "workspace:*"
"@blocksuite/icons": "npm:2.1.34"
- "@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 +617,7 @@ __metadata:
"@affine/plugin-cli": "workspace:*"
"@affine/sdk": "workspace:*"
"@blocksuite/icons": "npm:2.1.34"
- "@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"
@@ -12546,9 +12546,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"
@@ -12560,7 +12560,7 @@ __metadata:
clsx: ^2
react: ^18
react-dom: ^18
- checksum: fb168a83cd04da654ae1723098bf6902bf0eef8be9410a0814514b062d87e5fd2b972746a7770f8ca831a69d4a53255c87750e9fa8beab040d88deebc646dd92
+ checksum: 1e74a620d82bc6f6c318ccac35a2f4f86d5e3b97761e798d0ad3f3b31a74f377e402338ad00a2af54c62dda7d88c231cc07b53f2aa9c9bff1de5088659e1c712
languageName: node
linkType: hard
From e0f7ac426c4461fc86caca54fb0e369010611ed4 Mon Sep 17 00:00:00 2001
From: JimmFly
Date: Thu, 26 Oct 2023 16:15:12 +0800
Subject: [PATCH 17/27] feat(core): add translation key for payment (#4723)
---
.../setting-components/storage-progess.tsx | 24 +-
.../affine/auth/user-plan-button.tsx | 5 +-
.../setting-modal/account-setting/index.tsx | 1 +
.../general-setting/billing/index.tsx | 250 ++++++++++++------
.../general-setting/billing/style.css.ts | 14 +
.../setting-modal/general-setting/index.tsx | 6 +-
packages/frontend/i18n/src/resources/en.json | 33 ++-
7 files changed, 236 insertions(+), 97 deletions(-)
diff --git a/packages/frontend/component/src/components/setting-components/storage-progess.tsx b/packages/frontend/component/src/components/setting-components/storage-progess.tsx
index 33dd170182..4532a88ed0 100644
--- a/packages/frontend/component/src/components/setting-components/storage-progess.tsx
+++ b/packages/frontend/component/src/components/setting-components/storage-progess.tsx
@@ -1,3 +1,4 @@
+import { SubscriptionPlan } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
@@ -11,7 +12,12 @@ export interface StorageProgressProgress {
max: number;
value: number;
onUpgrade: () => void;
- plan: string;
+ plan: SubscriptionPlan;
+}
+
+enum ButtonType {
+ Primary = 'primary',
+ Default = 'default',
}
export const StorageProgress = ({
@@ -30,10 +36,10 @@ export const StorageProgress = ({
const max = useMemo(() => bytes.format(upperLimit), [upperLimit]);
const buttonType = useMemo(() => {
- if (plan === 'Free') {
- return 'primary';
+ if (plan === SubscriptionPlan.Free) {
+ return ButtonType.Primary;
}
- return 'default';
+ return ButtonType.Default;
}, [plan]);
return (
@@ -43,7 +49,7 @@ export const StorageProgress = ({
{t['com.affine.storage.used.hint']()}
{used}/{max}
- {` (${plan} Plan)`}
+ {` (${plan} ${t['com.affine.storage.plan']()})`}
@@ -59,9 +65,7 @@ export const StorageProgress = ({
- {plan === 'Free' ? 'Upgrade' : 'Change'}
+ {plan === 'Free'
+ ? t['com.affine.storage.upgrade']()
+ : t['com.affine.storage.change-plan']()}
diff --git a/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx b/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx
index be9e4ca8ae..580db2ea11 100644
--- a/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx
+++ b/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx
@@ -1,4 +1,5 @@
import { SubscriptionPlan } from '@affine/graphql';
+import { useAFFiNEI18N } from '@affine/i18n/hooks';
import Tooltip from '@toeverything/components/tooltip';
import { useSetAtom } from 'jotai';
import { useCallback } from 'react';
@@ -24,8 +25,10 @@ export const UserPlanButton = () => {
[setSettingModalAtom]
);
+ const t = useAFFiNEI18N();
+
return (
-
+
{plan}
diff --git a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx
index 8a5541b1a0..34c50bbf58 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx
@@ -157,6 +157,7 @@ const StoragePanel = () => {
const [subscription] = useUserSubscription();
const plan = subscription?.plan ?? SubscriptionPlan.Free;
+
const maxLimit = useMemo(() => {
return bytes.parse(plan === SubscriptionPlan.Free ? '10GB' : '100GB');
}, [plan]);
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx
index 146f982470..a1eab2ab5d 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx
@@ -15,11 +15,13 @@ import {
SubscriptionRecurring,
SubscriptionStatus,
} from '@affine/graphql';
+import { Trans } from '@affine/i18n';
+import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useMutation, useQuery } from '@affine/workspace/affine/gql';
import { ArrowRightSmallIcon } from '@blocksuite/icons';
import { Button, IconButton } from '@toeverything/components/button';
import { useSetAtom } from 'jotai';
-import { Suspense, useCallback } from 'react';
+import { Suspense, useCallback, useMemo } from 'react';
import { openSettingModalAtom } from '../../../../../atoms';
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
@@ -29,8 +31,25 @@ import {
} from '../../../../../hooks/use-subscription';
import * as styles from './style.css';
+enum DescriptionI18NKey {
+ Basic = 'com.affine.payment.billing-setting.current-plan.description',
+ Monthly = 'com.affine.payment.billing-setting.current-plan.description.monthly',
+ Yearly = 'com.affine.payment.billing-setting.current-plan.description.yearly',
+}
+
+const getMessageKey = (
+ plan: SubscriptionPlan,
+ recurring: SubscriptionRecurring
+): DescriptionI18NKey => {
+ if (plan !== SubscriptionPlan.Pro) {
+ return DescriptionI18NKey.Basic;
+ }
+ return DescriptionI18NKey[recurring];
+};
+
export const BillingSettings = () => {
const status = useCurrentLoginStatus();
+ const t = useAFFiNEI18N();
if (status !== 'authenticated') {
return null;
@@ -39,18 +58,22 @@ export const BillingSettings = () => {
return (
<>
{/* TODO: loading fallback */}
-
+
{/* TODO: loading fallback */}
-
+
@@ -74,75 +97,11 @@ const SubscriptionSettings = () => {
: price
? recurring === SubscriptionRecurring.Monthly
? String(price.amount / 100)
- : (price.yearlyAmount / 100 / 12).toFixed(2)
+ : String(price.yearlyAmount / 100)
: '?';
- return (
-
-
- {subscription?.status === SubscriptionStatus.Active && (
- <>
-
-
-
- {subscription.nextBillAt && (
-
- )}
- {subscription.canceledAt ? (
-
-
-
- ) : (
-
-
-
- )}
- >
- )}
-
- );
-};
+ const t = useAFFiNEI18N();
-const PlanAction = ({ plan }: { plan: string }) => {
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
const gotoPlansSetting = useCallback(() => {
@@ -153,13 +112,120 @@ const PlanAction = ({ plan }: { plan: string }) => {
});
}, [setOpenSettingModalAtom]);
+ const currentPlanDesc = useMemo(() => {
+ const messageKey = getMessageKey(plan, recurring);
+ return (
+
+ ),
+ }}
+ />
+ );
+ }, [plan, recurring, gotoPlansSetting]);
+
+ return (
+
+
+
+
+ ${amount}
+
+ /
+ {recurring === SubscriptionRecurring.Monthly
+ ? t['com.affine.payment.billing-setting.month']()
+ : t['com.affine.payment.billing-setting.year']()}
+
+
+
+ {subscription?.status === SubscriptionStatus.Active && (
+ <>
+
+
+
+ {subscription.nextBillAt && (
+
+ )}
+ {subscription.canceledAt ? (
+
+
+
+ ) : (
+
+
+
+ )}
+ >
+ )}
+
+ );
+};
+
+const PlanAction = ({
+ plan,
+ gotoPlansSetting,
+}: {
+ plan: string;
+ gotoPlansSetting: () => void;
+}) => {
+ const t = useAFFiNEI18N();
+
return (
- {plan === SubscriptionPlan.Free ? 'Upgrade' : 'Change Plan'}
+ {plan === SubscriptionPlan.Free
+ ? t['com.affine.payment.billing-setting.upgrade']()
+ : t['com.affine.payment.billing-setting.change-plan']()}
);
};
@@ -169,6 +235,7 @@ const PaymentMethodUpdater = () => {
const { isMutating, trigger } = useMutation({
mutation: createCustomerPortalMutation,
});
+ const t = useAFFiNEI18N();
const update = useCallback(() => {
trigger(null, {
@@ -179,8 +246,13 @@ const PaymentMethodUpdater = () => {
}, [trigger]);
return (
-
- Update
+
+ {t['com.affine.payment.billing-setting.upgrade']()}
);
};
@@ -190,6 +262,7 @@ const ResumeSubscription = ({
}: {
onSubscriptionUpdate: SubscriptionMutator;
}) => {
+ const t = useAFFiNEI18N();
const { isMutating, trigger } = useMutation({
mutation: resumeSubscriptionMutation,
});
@@ -203,8 +276,13 @@ const ResumeSubscription = ({
}, [trigger, onSubscriptionUpdate]);
return (
-
- Resume
+
+ {t['com.affine.payment.billing-setting.resume-subscription']()}
);
};
@@ -237,6 +315,7 @@ const CancelSubscription = ({
};
const BillingHistory = () => {
+ const t = useAFFiNEI18N();
const { data: invoicesQueryResult } = useQuery({
query: invoicesQuery,
variables: {
@@ -250,7 +329,9 @@ const BillingHistory = () => {
return (
{invoices.length === 0 ? (
-
There are no invoices to display.
+
+ {t['com.affine.payment.billing-setting.no-invoice']()}
+
) : (
// TODO: pagination
invoices.map(invoice => (
@@ -266,21 +347,28 @@ const InvoiceLine = ({
}: {
invoice: NonNullable
['invoices'][0];
}) => {
+ const t = useAFFiNEI18N();
+
const open = useCallback(() => {
if (invoice.link) {
window.open(invoice.link, '_blank', 'noopener noreferrer');
}
}, [invoice.link]);
+
return (
$, cny => ¥
- desc={`${invoice.status === InvoiceStatus.Paid ? 'Paid' : ''} $${
- invoice.amount / 100
- }`}
+ desc={`${
+ invoice.status === InvoiceStatus.Paid
+ ? t['com.affine.payment.billing-setting.paid']()
+ : ''
+ } $${invoice.amount / 100}`}
>
- View Invoice
+
+ {t['com.affine.payment.billing-setting.view-invoice']()}
+
);
};
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/style.css.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/style.css.ts
index 74f87f2c5e..55243b36dd 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/style.css.ts
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/style.css.ts
@@ -25,6 +25,10 @@ export const planPrice = style({
fontWeight: 600,
});
+export const billingFrequency = style({
+ fontSize: 'var(--affine-font-base)',
+});
+
export const paymentMethod = style({
marginTop: '24px',
});
@@ -37,3 +41,13 @@ export const noInvoice = style({
color: 'var(--affine-text-secondary-color)',
fontSize: 'var(--affine-font-xs)',
});
+
+export const currentPlanName = style({
+ fontSize: 'var(--affine-font-xs)',
+ fontWeight: 500,
+ color: 'var(--affine-text-emphasis-color)',
+ cursor: 'pointer',
+});
+export const button = style({
+ padding: '4px 12px',
+});
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx
index 97012598b5..241170a1d0 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx
@@ -51,8 +51,7 @@ export const useGeneralSettingList = (): GeneralSettingList => {
},
{
key: 'plans',
- // TODO: i18n
- title: 'AFFiNE Cloud Plans',
+ title: t['com.affine.payment.title'](),
// TODO: icon
icon: KeyboardIcon,
testId: 'plans-panel-trigger',
@@ -75,8 +74,7 @@ export const useGeneralSettingList = (): GeneralSettingList => {
if (status === 'authenticated') {
settings.splice(3, 0, {
key: 'billing',
- // TODO: i18n
- title: 'Billing',
+ title: t['com.affine.payment.billing-setting.title'](),
// TODO: icon
icon: KeyboardIcon,
testId: 'billing-panel-trigger',
diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json
index 2cc521d8e2..4ae0bfd809 100644
--- a/packages/frontend/i18n/src/resources/en.json
+++ b/packages/frontend/i18n/src/resources/en.json
@@ -333,7 +333,9 @@
"com.affine.storage.extend.hint": "The usage has reached its maximum capacity, AFFiNE Cloud is currently in early access phase and is not supported for upgrading, please be patient and wait for our pricing plan. ",
"com.affine.storage.extend.link": "To get more information click here.",
"com.affine.storage.title": "AFFiNE Cloud Storage",
- "com.affine.storage.upgrade": "Upgrade to Pro",
+ "com.affine.storage.upgrade": "Upgrade",
+ "com.affine.storage.change-plan": "Change",
+ "com.affine.storage.plan": "Plan",
"com.affine.storage.used.hint": "Space used",
"com.affine.themeSettings.dark": "Dark",
"com.affine.themeSettings.light": "Light",
@@ -639,5 +641,32 @@
"com.affine.auth.sign-out.confirm-modal.title": "Sign out?",
"com.affine.auth.sign-out.confirm-modal.description": "After signing out, the Cloud Workspaces associated with this account will be removed from the current device, and signing in again will add them back.",
"com.affine.auth.sign-out.confirm-modal.cancel": "Cancel",
- "com.affine.auth.sign-out.confirm-modal.confirm": "Sign Out"
+ "com.affine.auth.sign-out.confirm-modal.confirm": "Sign Out",
+ "com.affine.storage.maximum-tips": "You have reached the maximum capacity limit for your current account",
+ "com.affine.payment.tag-tooltips": "See all plans",
+ "com.affine.payment.title": "Pricing Plans",
+ "com.affine.payment.billing-setting.title": "Billing",
+ "com.affine.payment.billing-setting.subtitle": "Manage your billing information and invoices.",
+ "com.affine.payment.billing-setting.information": "Information",
+ "com.affine.payment.billing-setting.history": "Billing history",
+ "com.affine.payment.billing-setting.current-plan": "Current Plan",
+ "com.affine.payment.billing-setting.current-plan.description": "You are current on the <1>{{planName}} plan1>.",
+ "com.affine.payment.billing-setting.current-plan.description.monthly": "You are current on the monthly <1>{{planName}} plan1>.",
+ "com.affine.payment.billing-setting.current-plan.description.yearly": "You are current on the yearly <1>{{planName}} plan1>.",
+ "com.affine.payment.billing-setting.month": "month",
+ "com.affine.payment.billing-setting.year": "year",
+ "com.affine.payment.billing-setting.payment-method": "Payment Method",
+ "com.affine.payment.billing-setting.payment-method.description": "Provided by Stripe.",
+ "com.affine.payment.billing-setting.renew-date": "Renew Date",
+ "com.affine.payment.billing-setting.renew-date.description": "Next billing date: {{renewDate}}",
+ "com.affine.payment.billing-setting.expiration-date": "Expiration Date",
+ "com.affine.payment.billing-setting.expiration-date.description": "Your subscription is valid until {{expirationDate}}",
+ "com.affine.payment.billing-setting.cancel-subscription": "Cancel Subscription",
+ "com.affine.payment.billing-setting.cancel-subscription.description": "Subscription cancelled, your pro account will expire on {{cancelDate}}",
+ "com.affine.payment.billing-setting.upgrade": "Upgrade",
+ "com.affine.payment.billing-setting.change-plan": "Change Plan",
+ "com.affine.payment.billing-setting.resume-subscription": "Resume",
+ "com.affine.payment.billing-setting.no-invoice": "There are no invoices to display.",
+ "com.affine.payment.billing-setting.paid": "Paid",
+ "com.affine.payment.billing-setting.view-invoice": "View Invoice"
}
From 9334a363c775220d420d7df1f10e419676ad6e60 Mon Sep 17 00:00:00 2001
From: liuyi
Date: Thu, 26 Oct 2023 20:37:52 +0800
Subject: [PATCH 18/27] chore(server): upgrade stripe sdk (#4733)
---
packages/backend/server/package.json | 2 +-
packages/backend/server/src/config/default.ts | 2 +-
yarn.lock | 10 +++++-----
3 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json
index 2571db2fb4..83f06abc53 100644
--- a/packages/backend/server/package.json
+++ b/packages/backend/server/package.json
@@ -72,7 +72,7 @@
"rxjs": "^7.8.1",
"semver": "^7.5.4",
"socket.io": "^4.7.2",
- "stripe": "^13.6.0",
+ "stripe": "^14.1.0",
"ws": "^8.14.2",
"yjs": "^13.6.8"
},
diff --git a/packages/backend/server/src/config/default.ts b/packages/backend/server/src/config/default.ts
index ae46469847..d49aad9709 100644
--- a/packages/backend/server/src/config/default.ts
+++ b/packages/backend/server/src/config/default.ts
@@ -215,7 +215,7 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
APIKey: '',
webhookKey: '',
},
- apiVersion: '2023-08-16',
+ apiVersion: '2023-10-16',
},
},
} satisfies AFFiNEConfig;
diff --git a/yarn.lock b/yarn.lock
index 43dd0fd792..395abe1a84 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -740,7 +740,7 @@ __metadata:
semver: "npm:^7.5.4"
sinon: "npm:^16.1.0"
socket.io: "npm:^4.7.2"
- stripe: "npm:^13.6.0"
+ stripe: "npm:^14.1.0"
supertest: "npm:^6.3.3"
ts-node: "npm:^10.9.1"
typescript: "npm:^5.2.2"
@@ -31981,13 +31981,13 @@ __metadata:
languageName: node
linkType: hard
-"stripe@npm:^13.6.0":
- version: 13.6.0
- resolution: "stripe@npm:13.6.0"
+"stripe@npm:^14.1.0":
+ version: 14.1.0
+ resolution: "stripe@npm:14.1.0"
dependencies:
"@types/node": "npm:>=8.1.0"
qs: "npm:^6.11.0"
- checksum: 3fae1ed3dc845166c36fb28e4297ec770bb1f1b35e88b0166c465a31d41216203341b1055bf63b653fa3c66cd5d2eb72fdfaec9b58a7d467d207645a12b2cde0
+ checksum: 1363bc6fed873a9d96312a7d33fb422b9e0ca32064cf027eee12cf5cd132a936d3e248013b59d7011ca5a40acaae9adfcbae175345f7c76ee778800ca1077756
languageName: node
linkType: hard
From edb6e0fd6905aa461ba5309c3700b0a58f8a8922 Mon Sep 17 00:00:00 2001
From: Cats Juice
Date: Thu, 26 Oct 2023 22:00:14 +0800
Subject: [PATCH 19/27] feat(core): pricing plans actions (#4724)
---
.../general-setting/plans/index.tsx | 89 +++-
.../general-setting/plans/layout.tsx | 32 +-
.../general-setting/plans/modals.tsx | 119 ++++++
.../general-setting/plans/plan-card.tsx | 383 +++++++++++++-----
.../general-setting/plans/style.css.ts | 33 +-
.../components/affine/setting-modal/index.tsx | 66 +--
.../affine/setting-modal/style.css.ts | 18 +-
packages/frontend/i18n/src/resources/en.json | 45 +-
8 files changed, 607 insertions(+), 178 deletions(-)
create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/modals.tsx
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx
index 6cb38e898c..4067782bd4 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx
@@ -1,10 +1,14 @@
import { RadioButton, RadioButtonGroup } from '@affine/component';
+import { pushNotificationAtom } from '@affine/component/notification-center';
import {
pricesQuery,
SubscriptionPlan,
SubscriptionRecurring,
} from '@affine/graphql';
+import { Trans } from '@affine/i18n';
+import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useQuery } from '@affine/workspace/affine/gql';
+import { useSetAtom } from 'jotai';
import { Suspense, useEffect, useRef, useState } from 'react';
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
@@ -14,10 +18,25 @@ import { type FixedPrice, getPlanDetail, PlanCard } from './plan-card';
import { PlansSkeleton } from './skeleton';
import * as styles from './style.css';
+const getRecurringLabel = ({
+ recurring,
+ t,
+}: {
+ recurring: SubscriptionRecurring;
+ t: ReturnType;
+}) => {
+ return recurring === SubscriptionRecurring.Monthly
+ ? t['com.affine.payment.recurring-monthly']()
+ : t['com.affine.payment.recurring-yearly']();
+};
+
const Settings = () => {
+ const t = useAFFiNEI18N();
const [subscription, mutateSubscription] = useUserSubscription();
+ const pushNotification = useSetAtom(pushNotificationAtom);
+
const loggedIn = useCurrentLoginStatus() === 'authenticated';
- const planDetail = getPlanDetail();
+ const planDetail = getPlanDetail(t);
const scrollWrapper = useRef(null);
const {
@@ -44,6 +63,9 @@ const Settings = () => {
);
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
+ const isCanceled = !!subscription?.canceledAt;
+ const currentRecurring =
+ subscription?.recurring ?? SubscriptionRecurring.Monthly;
const yearlyDiscount = (
planDetail.get(SubscriptionPlan.Pro) as FixedPrice | undefined
@@ -76,23 +98,39 @@ const Settings = () => {
}, [recurring]);
const subtitle = loggedIn ? (
-
- You are current on the {currentPlan} plan. If you have any questions,
- please contact our {/*TODO: add action*/}customer support .
-
+ isCanceled ? (
+
+ {t['com.affine.payment.subtitle-canceled']({
+ plan: `${getRecurringLabel({
+ recurring: currentRecurring,
+ t,
+ })} ${currentPlan}`,
+ })}
+
+ ) : (
+
+
+ You are current on the {{ currentPlan }} plan. If you have any
+ questions, please contact our
+
+ customer support
+
+ .
+
+
+ )
) : (
-
- This is the Pricing plans of AFFiNE Cloud. You can sign up or sign in to
- your account first.
-
+ {t['com.affine.payment.subtitle-not-signed-in']()}
);
- const getRecurringLabel = (recurring: SubscriptionRecurring) =>
- ({
- [SubscriptionRecurring.Monthly]: 'Monthly',
- [SubscriptionRecurring.Yearly]: 'Annually',
- })[recurring];
-
const tabs = (
{
>
{Object.values(SubscriptionRecurring).map(recurring => (
- {getRecurringLabel(recurring)}
+ {getRecurringLabel({ recurring, t })}
{recurring === SubscriptionRecurring.Yearly && yearlyDiscount && (
- {yearlyDiscount}% off
+ {t['com.affine.payment.discount-amount']({
+ amount: yearlyDiscount,
+ })}
)}
@@ -119,6 +159,21 @@ const Settings = () => {
{
+ pushNotification({
+ type: 'success',
+ title: t['com.affine.payment.updated-notify-title'](),
+ message: t['com.affine.payment.updated-notify-msg']({
+ plan:
+ detail.plan === SubscriptionPlan.Free
+ ? SubscriptionPlan.Free
+ : getRecurringLabel({
+ recurring: recurring as SubscriptionRecurring,
+ t,
+ }),
+ }),
+ });
+ }}
{...{ detail, subscription, recurring }}
/>
);
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx
index 424e944cd0..89388f4066 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx
@@ -1,4 +1,5 @@
import { SettingHeader } from '@affine/component/setting-components';
+import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowRightBigIcon } from '@blocksuite/icons';
import type { HtmlHTMLAttributes, ReactNode } from 'react';
@@ -14,32 +15,37 @@ export interface PlanLayoutProps
scrollRef?: React.RefObject;
}
-const SeeAllLink = () => (
-
- See all plans
- { }
-
-);
+const SeeAllLink = () => {
+ const t = useAFFiNEI18N();
+
+ return (
+
+ {t['com.affine.payment.see-all-plans']()}
+ { }
+
+ );
+};
export const PlanLayout = ({
subtitle,
tabs,
scroll,
- title = 'Pricing Plans',
+ title,
footer = ,
scrollRef,
}: PlanLayoutProps) => {
+ const t = useAFFiNEI18N();
return (
{/* TODO: SettingHeader component shouldn't have margin itself */}
{tabs}
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/modals.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/modals.tsx
new file mode 100644
index 0000000000..cdd208228e
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/modals.tsx
@@ -0,0 +1,119 @@
+import { useAFFiNEI18N } from '@affine/i18n/hooks';
+import { DialogTrigger } from '@radix-ui/react-dialog';
+import { Button } from '@toeverything/components/button';
+import {
+ ConfirmModal,
+ type ConfirmModalProps,
+ Modal,
+} from '@toeverything/components/modal';
+import { type ReactNode, useEffect, useRef } from 'react';
+
+import * as styles from './style.css';
+
+/**
+ *
+ * @param param0
+ * @returns
+ */
+export const ConfirmLoadingModal = ({
+ type,
+ loading,
+ open,
+ content,
+ onOpenChange,
+ onConfirm,
+ ...props
+}: {
+ type: 'resume' | 'change';
+ loading?: boolean;
+ content?: ReactNode;
+} & ConfirmModalProps) => {
+ const t = useAFFiNEI18N();
+ const confirmed = useRef(false);
+
+ const title = t[`com.affine.payment.modal.${type}.title`]();
+ const confirmText = t[`com.affine.payment.modal.${type}.confirm`]();
+ const cancelText = t[`com.affine.payment.modal.${type}.cancel`]();
+ const contentText =
+ type !== 'change' ? t[`com.affine.payment.modal.${type}.content`]() : '';
+
+ useEffect(() => {
+ if (!loading && open && confirmed.current) {
+ onOpenChange?.(false);
+ confirmed.current = false;
+ }
+ }, [loading, open, onOpenChange]);
+
+ return (
+
{
+ confirmed.current = true;
+ onConfirm?.();
+ }}
+ {...props}
+ >
+ {content ?? contentText}
+
+ );
+};
+
+/**
+ * Downgrade modal, confirm & cancel button are reversed
+ * @param param0
+ */
+export const DowngradeModal = ({
+ open,
+ onOpenChange,
+ onCancel,
+}: {
+ loading?: boolean;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ onCancel?: () => void;
+}) => {
+ const t = useAFFiNEI18N();
+
+ return (
+
+
+
+ {t['com.affine.payment.modal.downgrade.content']()}
+
+
+ {t['com.affine.payment.modal.downgrade.caption']()}
+
+
+
+
+ {
+ onOpenChange?.(false);
+ onCancel?.();
+ }}
+ >
+ {t['com.affine.payment.modal.downgrade.cancel']()}
+
+
+ onOpenChange?.(false)} type="primary">
+ {t['com.affine.payment.modal.downgrade.confirm']()}
+
+
+
+
+ );
+};
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx
index 45c90ab68b..58b32f61c3 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx
@@ -5,19 +5,32 @@ import type {
import {
cancelSubscriptionMutation,
checkoutMutation,
+ resumeSubscriptionMutation,
SubscriptionPlan,
SubscriptionRecurring,
updateSubscriptionMutation,
} from '@affine/graphql';
+import { Trans } from '@affine/i18n';
+import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useMutation } from '@affine/workspace/affine/gql';
import { DoneIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
+import { Tooltip } from '@toeverything/components/tooltip';
+import { useSetAtom } from 'jotai';
import { useAtom } from 'jotai';
-import { type PropsWithChildren, useCallback, useEffect, useRef } from 'react';
+import {
+ type PropsWithChildren,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
import { openPaymentDisableAtom } from '../../../../../atoms';
+import { authAtom } from '../../../../../atoms/index';
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
import { BulledListIcon } from './icons/bulled-list';
+import { ConfirmLoadingModal, DowngradeModal } from './modals';
import * as styles from './style.css';
export interface FixedPrice {
@@ -41,12 +54,13 @@ interface PlanCardProps {
subscription?: Subscription | null;
recurring: string;
onSubscriptionUpdate: SubscriptionMutator;
+ onNotify: (info: {
+ detail: FixedPrice | DynamicPrice;
+ recurring: string;
+ }) => void;
}
-export function getPlanDetail() {
- // const t = useAFFiNEI18N();
-
- // TODO: i18n all things
+export function getPlanDetail(t: ReturnType
) {
return new Map([
[
SubscriptionPlan.Free,
@@ -56,12 +70,12 @@ export function getPlanDetail() {
price: '0',
yearlyPrice: '0',
benefits: [
- 'Unlimited local workspace',
- 'Unlimited login devices',
- 'Unlimited blocks',
- 'AFFiNE Cloud Storage 10GB',
- 'The maximum file size is 10M',
- 'Number of members per Workspace ≤ 3',
+ t['com.affine.payment.benefit-1'](),
+ t['com.affine.payment.benefit-2'](),
+ t['com.affine.payment.benefit-3'](),
+ t['com.affine.payment.benefit-4']({ capacity: '10GB' }),
+ t['com.affine.payment.benefit-5']({ capacity: '10M' }),
+ t['com.affine.payment.benefit-6']({ capacity: '3' }),
],
},
],
@@ -73,12 +87,12 @@ export function getPlanDetail() {
price: '1',
yearlyPrice: '1',
benefits: [
- 'Unlimited local workspace',
- 'Unlimited login devices',
- 'Unlimited blocks',
- 'AFFiNE Cloud Storage 100GB',
- 'The maximum file size is 500M',
- 'Number of members per Workspace ≤ 10',
+ t['com.affine.payment.benefit-1'](),
+ t['com.affine.payment.benefit-2'](),
+ t['com.affine.payment.benefit-3'](),
+ t['com.affine.payment.benefit-4']({ capacity: '100GB' }),
+ t['com.affine.payment.benefit-5']({ capacity: '500M' }),
+ t['com.affine.payment.benefit-6']({ capacity: '10' }),
],
},
],
@@ -89,9 +103,9 @@ export function getPlanDetail() {
plan: SubscriptionPlan.Team,
contact: true,
benefits: [
- 'Best team workspace for collaboration and knowledge distilling.',
- 'Focusing on what really matters with team project management and automation.',
- 'Pay for seats, fits all team size.',
+ t['com.affine.payment.dynamic-benefit-1'](),
+ t['com.affine.payment.dynamic-benefit-2'](),
+ t['com.affine.payment.dynamic-benefit-3'](),
],
},
],
@@ -102,20 +116,16 @@ export function getPlanDetail() {
plan: SubscriptionPlan.Enterprise,
contact: true,
benefits: [
- 'Solutions & best practices for dedicated needs.',
- 'Embedable & interrogations with IT support.',
+ t['com.affine.payment.dynamic-benefit-4'](),
+ t['com.affine.payment.dynamic-benefit-5'](),
],
},
],
]);
}
-export const PlanCard = ({
- detail,
- subscription,
- recurring,
- onSubscriptionUpdate,
-}: PlanCardProps) => {
+export const PlanCard = (props: PlanCardProps) => {
+ const { detail, subscription, recurring } = props;
const loggedIn = useCurrentLoginStatus() === 'authenticated';
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
const currentRecurring = subscription?.recurring;
@@ -160,49 +170,7 @@ export const PlanCard = ({
)}
- {
- // branches:
- // if contact => 'Contact Sales'
- // if not signed in:
- // if free => 'Sign up free'
- // else => 'Buy Pro'
- // else
- // if isCurrent => 'Current Plan'
- // else if free => 'Downgrade'
- // else if currentRecurring !== recurring => 'Change to {recurring} Billing'
- // else => 'Upgrade'
- // TODO: should replace with components with proper actions
- detail.type === 'dynamic' ? (
-
- ) : loggedIn ? (
- detail.plan === currentPlan &&
- (currentRecurring === recurring ||
- (!currentRecurring && detail.plan === SubscriptionPlan.Free)) ? (
-
- ) : detail.plan === SubscriptionPlan.Free ? (
-
- ) : currentRecurring !== recurring &&
- currentPlan === detail.plan ? (
-
- ) : (
-
- )
- ) : (
-
- {detail.plan === SubscriptionPlan.Free
- ? 'Sign up free'
- : 'Buy Pro'}
-
- )
- }
+
{detail.benefits.map((content, i) => (
@@ -226,15 +194,111 @@ export const PlanCard = ({
);
};
+const ActionButton = ({
+ detail,
+ subscription,
+ recurring,
+ onSubscriptionUpdate,
+ onNotify,
+}: PlanCardProps) => {
+ const t = useAFFiNEI18N();
+ const loggedIn = useCurrentLoginStatus() === 'authenticated';
+ const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
+ const currentRecurring = subscription?.recurring;
+
+ const mutateAndNotify = useCallback(
+ (sub: Parameters
[0]) => {
+ onSubscriptionUpdate?.(sub);
+ onNotify?.({ detail, recurring });
+ },
+ [onSubscriptionUpdate, onNotify, detail, recurring]
+ );
+
+ // branches:
+ // if contact => 'Contact Sales'
+ // if not signed in:
+ // if free => 'Sign up free'
+ // else => 'Buy Pro'
+ // else
+ // if isCurrent
+ // if canceled => 'Resume'
+ // else => 'Current Plan'
+ // if isCurrent => 'Current Plan'
+ // else if free => 'Downgrade'
+ // else if currentRecurring !== recurring => 'Change to {recurring} Billing'
+ // else => 'Upgrade'
+
+ // contact
+ if (detail.type === 'dynamic') {
+ return ;
+ }
+
+ // not signed in
+ if (!loggedIn) {
+ return (
+
+ {detail.plan === SubscriptionPlan.Free
+ ? t['com.affine.payment.sign-up-free']()
+ : t['com.affine.payment.buy-pro']()}
+
+ );
+ }
+
+ const isCanceled = !!subscription?.canceledAt;
+ const isFree = detail.plan === SubscriptionPlan.Free;
+ const isCurrent =
+ detail.plan === currentPlan &&
+ (isFree ? true : currentRecurring === recurring);
+
+ // is current
+ if (isCurrent) {
+ return isCanceled ? (
+
+ ) : (
+
+ );
+ }
+
+ if (isFree) {
+ return (
+
+ );
+ }
+
+ return currentPlan === detail.plan ? (
+
+ ) : (
+
+ );
+};
+
const CurrentPlan = () => {
- return Current Plan ;
+ const t = useAFFiNEI18N();
+ return (
+
+ {t['com.affine.payment.current-plan']()}
+
+ );
};
const Downgrade = ({
+ disabled,
onSubscriptionUpdate,
}: {
+ disabled?: boolean;
onSubscriptionUpdate: SubscriptionMutator;
}) => {
+ const t = useAFFiNEI18N();
+ const [open, setOpen] = useState(false);
const { isMutating, trigger } = useMutation({
mutation: cancelSubscriptionMutation,
});
@@ -247,25 +311,43 @@ const Downgrade = ({
});
}, [trigger, onSubscriptionUpdate]);
+ const tooltipContent = disabled
+ ? t['com.affine.payment.downgraded-tooltip']()
+ : null;
+
return (
-
- Downgrade
-
+ <>
+
+
+ setOpen(true)}
+ disabled={disabled || isMutating}
+ loading={isMutating}
+ >
+ {t['com.affine.payment.downgrade']()}
+
+
+
+
+ >
);
};
const ContactSales = () => {
+ const t = useAFFiNEI18N();
return (
- // TODO: add action
-
- Contact Sales
-
+
+
+ {t['com.affine.payment.contact-sales']()}
+
+
);
};
@@ -276,6 +358,7 @@ const Upgrade = ({
recurring: SubscriptionRecurring;
onSubscriptionUpdate: SubscriptionMutator;
}) => {
+ const t = useAFFiNEI18N();
const { isMutating, trigger } = useMutation({
mutation: checkoutMutation,
});
@@ -330,27 +413,35 @@ const Upgrade = ({
}, [onClose]);
return (
-
- Upgrade
-
+ <>
+
+ {t['com.affine.payment.upgrade']()}
+
+ >
);
};
const ChangeRecurring = ({
- from: _from /* TODO: from can be useful when showing confirmation modal */,
+ from,
to,
+ disabled,
+ due,
onSubscriptionUpdate,
}: {
from: SubscriptionRecurring;
to: SubscriptionRecurring;
+ disabled?: boolean;
+ due: string;
onSubscriptionUpdate: SubscriptionMutator;
}) => {
+ const t = useAFFiNEI18N();
+ const [open, setOpen] = useState(false);
const { isMutating, trigger } = useMutation({
mutation: updateSubscriptionMutation,
});
@@ -366,24 +457,102 @@ const ChangeRecurring = ({
);
}, [trigger, onSubscriptionUpdate, to]);
+ const changeCurringContent = (
+
+ You are changing your {from} {' '}
+ subscription to {to} {' '}
+ subscription. This change will take effect in the next billing cycle, with
+ an effective date of {due} .
+
+ );
+
return (
-
- Change to {to} Billing
-
+ <>
+ setOpen(true)}
+ disabled={disabled || isMutating}
+ loading={isMutating}
+ >
+ {t['com.affine.payment.change-to']({ to })}
+
+
+
+ >
);
};
-const SignupAction = ({ children }: PropsWithChildren) => {
- // TODO: add login action
+const SignUpAction = ({ children }: PropsWithChildren) => {
+ const setOpen = useSetAtom(authAtom);
+
+ const onClickSignIn = useCallback(async () => {
+ setOpen(state => ({
+ ...state,
+ openModal: true,
+ }));
+ }, [setOpen]);
+
return (
-
+
{children}
);
};
+
+const ResumeAction = ({
+ onSubscriptionUpdate,
+}: {
+ onSubscriptionUpdate: SubscriptionMutator;
+}) => {
+ const t = useAFFiNEI18N();
+ const [open, setOpen] = useState(false);
+ const [hovered, setHovered] = useState(false);
+ const { isMutating, trigger } = useMutation({
+ mutation: resumeSubscriptionMutation,
+ });
+
+ const resume = useCallback(() => {
+ trigger(null, {
+ onSuccess: data => {
+ onSubscriptionUpdate(data.resumeSubscription);
+ },
+ });
+ }, [trigger, onSubscriptionUpdate]);
+
+ return (
+ <>
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ onClick={() => setOpen(true)}
+ loading={isMutating}
+ disabled={isMutating}
+ >
+ {hovered
+ ? t['com.affine.payment.resume-renewal']()
+ : t['com.affine.payment.current-plan']()}
+
+
+
+ >
+ );
+};
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts
index 4189024ff9..a1c5ac5abb 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/style.css.ts
@@ -14,7 +14,7 @@ export const radioButtonDiscount = style({
});
export const planCardsWrapper = style({
- paddingRight: 'calc(var(--setting-modal-gap-x))',
+ paddingRight: 'calc(var(--setting-modal-gap-x) + 30px)',
display: 'flex',
gap: '16px',
width: 'fit-content',
@@ -116,3 +116,34 @@ export const planBenefitText = style({
flexDirection: 'column',
alignItems: 'center',
});
+
+export const downgradeContentWrapper = style({
+ padding: '12px 0 20px 0px',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '12px',
+});
+
+export const downgradeContent = style({
+ fontSize: '15px',
+ lineHeight: '24px',
+ fontWeight: 400,
+ color: 'var(--affine-text-primary-color)',
+});
+
+export const downgradeCaption = style({
+ fontSize: '14px',
+ lineHeight: '22px',
+ color: 'var(--affine-text-secondary-color)',
+});
+
+export const downgradeFooter = style({
+ display: 'flex',
+ justifyContent: 'flex-end',
+ gap: '20px',
+ paddingTop: '20px',
+});
+
+export const textEmphasis = style({
+ color: 'var(--affine-text-emphasis-color)',
+});
diff --git a/packages/frontend/core/src/components/affine/setting-modal/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/index.tsx
index dbac104bd7..7ed213a44e 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/index.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/index.tsx
@@ -39,6 +39,7 @@ export const SettingModal = ({
const generalSettingList = useGeneralSettingList();
const modalContentRef = useRef(null);
+ const modalContentWrapperRef = useRef(null);
useEffect(() => {
if (!modalProps.open) return;
@@ -46,19 +47,18 @@ export const SettingModal = ({
const onResize = debounce(() => {
cancelAnimationFrame(animationFrameId);
animationFrameId = requestAnimationFrame(() => {
- if (!modalContentRef.current) return;
+ if (!modalContentRef.current || !modalContentWrapperRef.current) return;
+ const wrapperWidth = modalContentWrapperRef.current.offsetWidth;
const contentWidth = modalContentRef.current.offsetWidth;
- const computedStyle = window.getComputedStyle(modalContentRef.current);
- const marginX = parseInt(computedStyle.marginLeft, 10);
- const paddingX = parseInt(computedStyle.paddingLeft, 10);
+
modalContentRef.current?.style.setProperty(
'--setting-modal-width',
- `${contentWidth + marginX * 2}px`
+ `${wrapperWidth}px`
);
modalContentRef.current?.style.setProperty(
'--setting-modal-gap-x',
- `${marginX + paddingX}px`
+ `${(wrapperWidth - contentWidth) / 2}px`
);
});
}, 200);
@@ -121,33 +121,35 @@ export const SettingModal = ({
-
- {activeTab === 'workspace' && workspaceId ? (
-
}>
-
-
- ) : null}
- {generalSettingList.find(v => v.key === activeTab) ? (
-
- ) : null}
- {activeTab === 'account' && loginStatus === 'authenticated' ? (
-
- ) : null}
-
-
-
-
-
-
- {t['com.affine.settings.suggestion']()}
-
+
+
+ {activeTab === 'workspace' && workspaceId ? (
+
}>
+
+
+ ) : null}
+ {generalSettingList.find(v => v.key === activeTab) ? (
+
+ ) : null}
+ {activeTab === 'account' && loginStatus === 'authenticated' ? (
+
+ ) : null}
+
+
diff --git a/packages/frontend/core/src/components/affine/setting-modal/style.css.ts b/packages/frontend/core/src/components/affine/setting-modal/style.css.ts
index 9ba1ab1c48..786f42111e 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/style.css.ts
+++ b/packages/frontend/core/src/components/affine/setting-modal/style.css.ts
@@ -3,21 +3,25 @@ import { style } from '@vanilla-extract/css';
export const wrapper = style({
flexGrow: '1',
height: '100%',
- maxWidth: '560px',
- margin: '0 auto',
- padding: '40px 15px 20px 15px',
- // children
+ // margin: '0 auto',
+ padding: '40px 15px 20px 15px',
+ overflowX: 'hidden',
+ overflowY: 'auto',
+
display: 'flex',
- flexDirection: 'column',
- justifyContent: 'space-between',
- alignItems: 'center',
+ justifyContent: 'center',
'::-webkit-scrollbar': {
display: 'none',
},
});
+export const centerContainer = style({
+ width: '100%',
+ maxWidth: '560px',
+});
+
export const content = style({
width: '100%',
marginBottom: '24px',
diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json
index 4ae0bfd809..d8b0c50c4d 100644
--- a/packages/frontend/i18n/src/resources/en.json
+++ b/packages/frontend/i18n/src/resources/en.json
@@ -642,9 +642,52 @@
"com.affine.auth.sign-out.confirm-modal.description": "After signing out, the Cloud Workspaces associated with this account will be removed from the current device, and signing in again will add them back.",
"com.affine.auth.sign-out.confirm-modal.cancel": "Cancel",
"com.affine.auth.sign-out.confirm-modal.confirm": "Sign Out",
+ "com.affine.payment.recurring-yearly": "Annually",
+ "com.affine.payment.recurring-monthly": "Monthly",
+ "com.affine.payment.title": "Pricing Plans",
+ "com.affine.payment.subtitle-not-signed-in": "This is the Pricing plans of AFFiNE Cloud. You can sign up or sign in to your account first.",
+ "com.affine.payment.subtitle-active": "You are current on the {{currentPlan}} plan. If you have any questions, please contact our <3>customer support3>.",
+ "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>from0> subscription to <1>to1> subscription. This change will take effect in the next billing cycle, with an effective date of <2>due2>.",
+ "com.affine.payment.modal.change.cancel": "Cancel",
+ "com.affine.payment.modal.change.confirm": "Change",
+ "com.affine.payment.updated-notify-title": "Subscription updated",
+ "com.affine.payment.updated-notify-msg": "You have changed your plan to {{plan}} billing.",
"com.affine.storage.maximum-tips": "You have reached the maximum capacity limit for your current account",
"com.affine.payment.tag-tooltips": "See all plans",
- "com.affine.payment.title": "Pricing Plans",
"com.affine.payment.billing-setting.title": "Billing",
"com.affine.payment.billing-setting.subtitle": "Manage your billing information and invoices.",
"com.affine.payment.billing-setting.information": "Information",
From 50563dcb6ea8a5bdd42dfe2c049caab9985ec777 Mon Sep 17 00:00:00 2001
From: liuyi
Date: Fri, 27 Oct 2023 10:28:22 +0800
Subject: [PATCH 20/27] feat(server): auto attach early access coupon (#4728)
---
.../server/src/modules/payment/index.ts | 4 +
.../server/src/modules/payment/schedule.ts | 214 +++++++++++++++
.../server/src/modules/payment/service.ts | 254 +++++-------------
.../backend/server/src/modules/users/index.ts | 1 +
.../backend/server/src/modules/users/users.ts | 15 +-
5 files changed, 302 insertions(+), 186 deletions(-)
create mode 100644 packages/backend/server/src/modules/payment/schedule.ts
diff --git a/packages/backend/server/src/modules/payment/index.ts b/packages/backend/server/src/modules/payment/index.ts
index 1a51678848..30393504b5 100644
--- a/packages/backend/server/src/modules/payment/index.ts
+++ b/packages/backend/server/src/modules/payment/index.ts
@@ -1,12 +1,16 @@
import { Module } from '@nestjs/common';
+import { UsersModule } from '../users';
import { SubscriptionResolver, UserSubscriptionResolver } from './resolver';
+import { ScheduleManager } from './schedule';
import { SubscriptionService } from './service';
import { StripeProvider } from './stripe';
import { StripeWebhook } from './webhook';
@Module({
+ imports: [UsersModule],
providers: [
+ ScheduleManager,
StripeProvider,
SubscriptionService,
SubscriptionResolver,
diff --git a/packages/backend/server/src/modules/payment/schedule.ts b/packages/backend/server/src/modules/payment/schedule.ts
new file mode 100644
index 0000000000..423bdf6916
--- /dev/null
+++ b/packages/backend/server/src/modules/payment/schedule.ts
@@ -0,0 +1,214 @@
+import { Injectable } from '@nestjs/common';
+import Stripe from 'stripe';
+
+@Injectable()
+export class ScheduleManager {
+ private _schedule: Stripe.SubscriptionSchedule | null = null;
+
+ constructor(private readonly stripe: Stripe) {}
+
+ static create(stripe: Stripe, schedule?: Stripe.SubscriptionSchedule) {
+ const manager = new ScheduleManager(stripe);
+ if (schedule) {
+ manager._schedule = schedule;
+ }
+
+ return manager;
+ }
+
+ get schedule() {
+ return this._schedule;
+ }
+
+ get currentPhase() {
+ if (!this._schedule) {
+ return null;
+ }
+
+ return this._schedule.phases.find(
+ phase =>
+ phase.start_date * 1000 < Date.now() &&
+ phase.end_date * 1000 > Date.now()
+ );
+ }
+
+ get nextPhase() {
+ if (!this._schedule) {
+ return null;
+ }
+
+ return this._schedule.phases.find(
+ phase => phase.start_date * 1000 > Date.now()
+ );
+ }
+
+ get isActive() {
+ return this._schedule?.status === 'active';
+ }
+
+ async fromSchedule(schedule: string | Stripe.SubscriptionSchedule) {
+ if (typeof schedule === 'string') {
+ const s = await this.stripe.subscriptionSchedules
+ .retrieve(schedule)
+ .catch(() => undefined);
+
+ return ScheduleManager.create(this.stripe, s);
+ } else {
+ return ScheduleManager.create(this.stripe, schedule);
+ }
+ }
+
+ async fromSubscription(subscription: string | Stripe.Subscription) {
+ if (typeof subscription === 'string') {
+ subscription = await this.stripe.subscriptions.retrieve(subscription, {
+ expand: ['schedule'],
+ });
+ }
+
+ if (subscription.schedule) {
+ return await this.fromSchedule(subscription.schedule);
+ } else {
+ const schedule = await this.stripe.subscriptionSchedules.create({
+ from_subscription: subscription.id,
+ });
+
+ return await this.fromSchedule(schedule);
+ }
+ }
+
+ /**
+ * Cancel a subscription by marking schedule's end behavior to `cancel`.
+ * At the same time, the coming phase's price and coupon will be saved to metadata for later resuming to correction subscription.
+ */
+ async cancel() {
+ if (!this._schedule) {
+ throw new Error('No schedule');
+ }
+
+ if (!this.isActive || !this.currentPhase) {
+ throw new Error('Unexpected subscription schedule status');
+ }
+
+ const phases: Stripe.SubscriptionScheduleUpdateParams.Phase = {
+ items: [
+ {
+ price: this.currentPhase.items[0].price as string,
+ quantity: 1,
+ },
+ ],
+ coupon: (this.currentPhase.coupon as string | null) ?? undefined,
+ start_date: this.currentPhase.start_date,
+ end_date: this.currentPhase.end_date,
+ };
+
+ if (this.nextPhase) {
+ // cancel a subscription with a schedule exiting will delete the upcoming phase,
+ // it's hard to recover the subscription to the original state if user wan't to resume before due.
+ // so we manually save the next phase's key information to metadata for later easy resuming.
+ phases.metadata = {
+ next_coupon: (this.nextPhase.coupon as string | null) || null, // avoid empty string
+ next_price: this.nextPhase.items[0].price as string,
+ };
+ }
+
+ await this.stripe.subscriptionSchedules.update(this._schedule.id, {
+ phases: [phases],
+ end_behavior: 'cancel',
+ });
+ }
+
+ async resume() {
+ if (!this._schedule) {
+ throw new Error('No schedule');
+ }
+
+ if (!this.isActive || !this.currentPhase) {
+ throw new Error('Unexpected subscription schedule status');
+ }
+
+ const phases: Stripe.SubscriptionScheduleUpdateParams.Phase[] = [
+ {
+ items: [
+ {
+ price: this.currentPhase.items[0].price as string,
+ quantity: 1,
+ },
+ ],
+ coupon: (this.currentPhase.coupon as string | null) ?? undefined,
+ start_date: this.currentPhase.start_date,
+ end_date: this.currentPhase.end_date,
+ metadata: {
+ next_coupon: null,
+ next_price: null,
+ },
+ },
+ ];
+
+ if (this.currentPhase.metadata && this.currentPhase.metadata.next_price) {
+ phases.push({
+ items: [
+ {
+ price: this.currentPhase.metadata.next_price,
+ quantity: 1,
+ },
+ ],
+ coupon: this.currentPhase.metadata.next_coupon || undefined,
+ });
+ }
+
+ await this.stripe.subscriptionSchedules.update(this._schedule.id, {
+ phases: phases,
+ end_behavior: 'release',
+ });
+ }
+
+ async release() {
+ if (!this._schedule) {
+ throw new Error('No schedule');
+ }
+
+ await this.stripe.subscriptionSchedules.release(this._schedule.id);
+ }
+
+ async update(price: string, coupon?: string) {
+ if (!this._schedule) {
+ throw new Error('No schedule');
+ }
+
+ if (!this.isActive || !this.currentPhase) {
+ throw new Error('Unexpected subscription schedule status');
+ }
+
+ // if current phase's plan matches target, and no coupon change, just release the schedule
+ if (
+ this.currentPhase.items[0].price === price &&
+ (!coupon || this.currentPhase.coupon === coupon)
+ ) {
+ await this.stripe.subscriptionSchedules.release(this._schedule.id);
+ this._schedule = null;
+ } else {
+ await this.stripe.subscriptionSchedules.update(this._schedule.id, {
+ phases: [
+ {
+ items: [
+ {
+ price: this.currentPhase.items[0].price as string,
+ },
+ ],
+ start_date: this.currentPhase.start_date,
+ end_date: this.currentPhase.end_date,
+ },
+ {
+ items: [
+ {
+ price: price,
+ quantity: 1,
+ },
+ ],
+ coupon,
+ },
+ ],
+ });
+ }
+ }
+}
diff --git a/packages/backend/server/src/modules/payment/service.ts b/packages/backend/server/src/modules/payment/service.ts
index e9e4374b0b..91d98d2788 100644
--- a/packages/backend/server/src/modules/payment/service.ts
+++ b/packages/backend/server/src/modules/payment/service.ts
@@ -11,6 +11,8 @@ import Stripe from 'stripe';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
+import { UsersService } from '../users';
+import { ScheduleManager } from './schedule';
const OnEvent = (
event: Stripe.Event.Type,
@@ -65,7 +67,7 @@ export enum InvoiceStatus {
Uncollectible = 'uncollectible',
}
-export enum Coupon {
+export enum CouponType {
EarlyAccess = 'earlyaccess',
EarlyAccessRenew = 'earlyaccessrenew',
}
@@ -78,7 +80,9 @@ export class SubscriptionService {
constructor(
config: Config,
private readonly stripe: Stripe,
- private readonly db: PrismaService
+ private readonly db: PrismaService,
+ private readonly user: UsersService,
+ private readonly scheduleManager: ScheduleManager
) {
this.paymentConfig = config.payment;
@@ -117,8 +121,9 @@ export class SubscriptionService {
}
const price = await this.getPrice(plan, recurring);
-
const customer = await this.getOrCreateCustomer(user);
+ const coupon = await this.getAvailableCoupon(user, CouponType.EarlyAccess);
+
return await this.stripe.checkout.sessions.create({
line_items: [
{
@@ -126,10 +131,16 @@ export class SubscriptionService {
quantity: 1,
},
],
- allow_promotion_codes: true,
tax_id_collection: {
enabled: true,
},
+ ...(coupon
+ ? {
+ discounts: [{ coupon }],
+ }
+ : {
+ allow_promotion_codes: true,
+ }),
mode: 'subscription',
success_url: redirectUrl,
customer: customer.stripeCustomerId,
@@ -160,12 +171,16 @@ export class SubscriptionService {
// should release the schedule first
if (user.subscription.stripeScheduleId) {
- await this.cancelSubscriptionSchedule(user.subscription.stripeScheduleId);
+ const manager = await this.scheduleManager.fromSchedule(
+ user.subscription.stripeScheduleId
+ );
+ await manager.cancel();
return this.saveSubscription(
user,
await this.stripe.subscriptions.retrieve(
user.subscription.stripeSubscriptionId
- )
+ ),
+ false
);
} else {
// let customer contact support if they want to cancel immediately
@@ -203,12 +218,16 @@ export class SubscriptionService {
}
if (user.subscription.stripeScheduleId) {
- await this.resumeSubscriptionSchedule(user.subscription.stripeScheduleId);
+ const manager = await this.scheduleManager.fromSchedule(
+ user.subscription.stripeScheduleId
+ );
+ await manager.resume();
return this.saveSubscription(
user,
await this.stripe.subscriptions.retrieve(
user.subscription.stripeSubscriptionId
- )
+ ),
+ false
);
} else {
const subscription = await this.stripe.subscriptions.update(
@@ -252,27 +271,26 @@ export class SubscriptionService {
recurring
);
- let scheduleId: string | null;
- // a schedule existing
- if (user.subscription.stripeScheduleId) {
- scheduleId = await this.scheduleNewPrice(
- user.subscription.stripeScheduleId,
- price
- );
- } else {
- const schedule = await this.stripe.subscriptionSchedules.create({
- from_subscription: user.subscription.stripeSubscriptionId,
- });
- await this.scheduleNewPrice(schedule.id, price);
- scheduleId = schedule.id;
- }
+ const manager = await this.scheduleManager.fromSubscription(
+ user.subscription.stripeSubscriptionId
+ );
+
+ await manager.update(
+ price,
+ // if user is early access user, use early access coupon
+ manager.currentPhase?.coupon === CouponType.EarlyAccess ||
+ manager.currentPhase?.coupon === CouponType.EarlyAccessRenew ||
+ manager.nextPhase?.coupon === CouponType.EarlyAccessRenew
+ ? CouponType.EarlyAccessRenew
+ : undefined
+ );
return await this.db.userSubscription.update({
where: {
id: user.subscription.id,
},
data: {
- stripeScheduleId: scheduleId,
+ stripeScheduleId: manager.schedule?.id ?? null, // update schedule id or set to null(undefined means untouched)
recurring,
},
});
@@ -325,8 +343,26 @@ export class SubscriptionService {
});
}
- @OnEvent('invoice.created')
@OnEvent('invoice.paid')
+ async onInvoicePaid(stripeInvoice: Stripe.Invoice) {
+ await this.saveInvoice(stripeInvoice);
+
+ const line = stripeInvoice.lines.data[0];
+
+ if (!line.price || line.price.type !== 'recurring') {
+ throw new Error('Unknown invoice with no recurring price');
+ }
+
+ // deal with early access user
+ if (stripeInvoice.discount?.coupon.id === CouponType.EarlyAccess) {
+ const manager = await this.scheduleManager.fromSubscription(
+ line.subscription as string
+ );
+ await manager.update(line.price.id, CouponType.EarlyAccessRenew);
+ }
+ }
+
+ @OnEvent('invoice.created')
@OnEvent('invoice.finalization_failed')
@OnEvent('invoice.payment_failed')
async saveInvoice(stripeInvoice: Stripe.Invoice) {
@@ -591,165 +627,21 @@ export class SubscriptionService {
return prices.data[0].id;
}
- /**
- * If a subscription is managed by a schedule, it has a different way to cancel.
- */
- private async cancelSubscriptionSchedule(scheduleId: string) {
- const schedule =
- await this.stripe.subscriptionSchedules.retrieve(scheduleId);
-
- const currentPhase = schedule.phases.find(
- phase =>
- phase.start_date * 1000 < Date.now() &&
- phase.end_date * 1000 > Date.now()
- );
-
- if (
- schedule.status !== 'active' ||
- schedule.phases.length > 2 ||
- !currentPhase
- ) {
- throw new Error('Unexpected subscription schedule status');
- }
-
- if (schedule.status !== 'active') {
- throw new Error('unexpected subscription schedule status');
- }
-
- const nextPhase = schedule.phases.find(
- phase => phase.start_date * 1000 > Date.now()
- );
-
- if (!currentPhase) {
- throw new Error('Unexpected subscription schedule status');
- }
-
- const update: Stripe.SubscriptionScheduleUpdateParams.Phase = {
- items: [
- {
- price: currentPhase.items[0].price as string,
- quantity: 1,
- },
- ],
- coupon: (currentPhase.coupon as string | null) ?? undefined,
- start_date: currentPhase.start_date,
- end_date: currentPhase.end_date,
- };
-
- if (nextPhase) {
- // cancel a subscription with a schedule exiting will delete the upcoming phase,
- // it's hard to recover the subscription to the original state if user wan't to resume before due.
- // so we manually save the next phase's key information to metadata for later easy resuming.
- update.metadata = {
- next_coupon: (nextPhase.coupon as string | null) || null, // avoid empty string
- next_price: nextPhase.items[0].price as string,
- };
- }
-
- await this.stripe.subscriptionSchedules.update(schedule.id, {
- phases: [update],
- end_behavior: 'cancel',
- });
- }
-
- private async resumeSubscriptionSchedule(scheduleId: string) {
- const schedule =
- await this.stripe.subscriptionSchedules.retrieve(scheduleId);
-
- const currentPhase = schedule.phases.find(
- phase =>
- phase.start_date * 1000 < Date.now() &&
- phase.end_date * 1000 > Date.now()
- );
-
- if (schedule.status !== 'active' || !currentPhase) {
- throw new Error('Unexpected subscription schedule status');
- }
-
- const update: Stripe.SubscriptionScheduleUpdateParams.Phase[] = [
- {
- items: [
- {
- price: currentPhase.items[0].price as string,
- quantity: 1,
- },
- ],
- coupon: (currentPhase.coupon as string | null) ?? undefined,
- start_date: currentPhase.start_date,
- end_date: currentPhase.end_date,
- metadata: {
- next_coupon: null,
- next_price: null,
- },
- },
- ];
-
- if (currentPhase.metadata && currentPhase.metadata.next_price) {
- update.push({
- items: [
- {
- price: currentPhase.metadata.next_price,
- quantity: 1,
- },
- ],
- coupon: currentPhase.metadata.next_coupon || undefined,
- });
- }
-
- await this.stripe.subscriptionSchedules.update(schedule.id, {
- phases: update,
- end_behavior: 'release',
- });
- }
-
- /**
- * we only schedule a new price when user change the recurring plan and there is now upcoming phases.
- */
- private async scheduleNewPrice(
- scheduleId: string,
- priceId: string
+ private async getAvailableCoupon(
+ user: User,
+ couponType: CouponType
): Promise {
- const schedule =
- await this.stripe.subscriptionSchedules.retrieve(scheduleId);
-
- const currentPhase = schedule.phases.find(
- phase =>
- phase.start_date * 1000 < Date.now() &&
- phase.end_date * 1000 > Date.now()
- );
-
- if (schedule.status !== 'active' || !currentPhase) {
- throw new Error('Unexpected subscription schedule status');
+ const earlyAccess = await this.user.isEarlyAccessUser(user.email);
+ if (earlyAccess) {
+ try {
+ const coupon = await this.stripe.coupons.retrieve(couponType);
+ return coupon.valid ? coupon.id : null;
+ } catch (e) {
+ this.logger.error('Failed to get early access coupon', e);
+ return null;
+ }
}
- // if current phase's plan matches target, just release the schedule
- if (currentPhase.items[0].price === priceId) {
- await this.stripe.subscriptionSchedules.release(scheduleId);
- return null;
- } else {
- await this.stripe.subscriptionSchedules.update(schedule.id, {
- phases: [
- {
- items: [
- {
- price: currentPhase.items[0].price as string,
- },
- ],
- start_date: schedule.phases[0].start_date,
- end_date: schedule.phases[0].end_date,
- },
- {
- items: [
- {
- price: priceId,
- quantity: 1,
- },
- ],
- },
- ],
- });
-
- return scheduleId;
- }
+ return null;
}
}
diff --git a/packages/backend/server/src/modules/users/index.ts b/packages/backend/server/src/modules/users/index.ts
index 40dc84ae46..308a1a9a13 100644
--- a/packages/backend/server/src/modules/users/index.ts
+++ b/packages/backend/server/src/modules/users/index.ts
@@ -7,6 +7,7 @@ import { UsersService } from './users';
@Module({
imports: [StorageModule],
providers: [UserResolver, UsersService],
+ exports: [UsersService],
})
export class UsersModule {}
diff --git a/packages/backend/server/src/modules/users/users.ts b/packages/backend/server/src/modules/users/users.ts
index 477adc1fd3..1d56c936b2 100644
--- a/packages/backend/server/src/modules/users/users.ts
+++ b/packages/backend/server/src/modules/users/users.ts
@@ -15,16 +15,21 @@ export class UsersService {
async canEarlyAccess(email: string) {
if (this.config.featureFlags.earlyAccessPreview && !isStaff(email)) {
- return this.prisma.newFeaturesWaitingList
- .findUnique({
- where: { email, type: NewFeaturesKind.EarlyAccess },
- })
- .catch(() => false);
+ return this.isEarlyAccessUser(email);
} else {
return true;
}
}
+ async isEarlyAccessUser(email: string) {
+ return this.prisma.newFeaturesWaitingList
+ .count({
+ where: { email, type: NewFeaturesKind.EarlyAccess },
+ })
+ .then(count => count > 0)
+ .catch(() => false);
+ }
+
async getStorageQuotaById(id: string) {
const features = await this.prisma.user
.findUnique({
From 35dbbe561abcfc18dc23a0626f2a9a9761aea208 Mon Sep 17 00:00:00 2001
From: JimmFly
Date: Fri, 27 Oct 2023 14:25:48 +0800
Subject: [PATCH 21/27] feat(core): adjust member tips (#4737)
---
.../new-workspace-setting-detail/members.tsx | 50 +++++++++++++++++--
.../new-workspace-setting-detail/style.css.ts | 19 +++++++
packages/frontend/i18n/src/resources/en.json | 3 +-
3 files changed, 68 insertions(+), 4 deletions(-)
diff --git a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/members.tsx b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/members.tsx
index 38c425c9c4..66eef647c6 100644
--- a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/members.tsx
+++ b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/members.tsx
@@ -10,9 +10,9 @@ import { pushNotificationAtom } from '@affine/component/notification-center';
import { SettingRow } from '@affine/component/setting-components';
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
-import { Permission } from '@affine/graphql';
+import { Permission, SubscriptionPlan } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
-import { MoreVerticalIcon } from '@blocksuite/icons';
+import { ArrowRightBigIcon, MoreVerticalIcon } from '@blocksuite/icons';
import { Avatar } from '@toeverything/components/avatar';
import { Button, IconButton } from '@toeverything/components/button';
import { Loading } from '@toeverything/components/loading';
@@ -31,16 +31,24 @@ import {
} from 'react';
import { ErrorBoundary } from 'react-error-boundary';
+import { openSettingModalAtom } from '../../../atoms';
import type { CheckedUser } from '../../../hooks/affine/use-current-user';
import { useCurrentUser } from '../../../hooks/affine/use-current-user';
import { useInviteMember } from '../../../hooks/affine/use-invite-member';
import { useMemberCount } from '../../../hooks/affine/use-member-count';
import { type Member, useMembers } from '../../../hooks/affine/use-members';
import { useRevokeMemberPermission } from '../../../hooks/affine/use-revoke-member-permission';
+import { useUserSubscription } from '../../../hooks/use-subscription';
import { AnyErrorBoundary } from '../any-error-boundary';
import * as style from './style.css';
import type { WorkspaceSettingDetailProps } from './types';
+enum MemberLimitCount {
+ Free = '3',
+ Pro = '10',
+ Other = '?',
+}
+
const COUNT_PER_PAGE = 8;
export interface MembersPanelProps extends WorkspaceSettingDetailProps {
workspace: AffineOfficialWorkspace;
@@ -130,11 +138,47 @@ export const CloudWorkspaceMembersPanel = ({
[pushNotification, revokeMemberPermission, t]
);
+ const setSettingModalAtom = useSetAtom(openSettingModalAtom);
+ const handleUpgrade = useCallback(() => {
+ setSettingModalAtom({
+ open: true,
+ activeTab: 'plans',
+ workspaceId: null,
+ });
+ }, [setSettingModalAtom]);
+
+ const [subscription] = useUserSubscription();
+ const plan = subscription?.plan ?? SubscriptionPlan.Free;
+ const memberLimit = useMemo(() => {
+ if (plan === SubscriptionPlan.Free) {
+ return MemberLimitCount.Free;
+ }
+ if (plan === SubscriptionPlan.Pro) {
+ return MemberLimitCount.Pro;
+ }
+ return MemberLimitCount.Other;
+ }, [plan]);
+ const desc = useMemo(() => {
+ return (
+
+ {t['com.affine.payment.member.description']({
+ planName: plan,
+ memberLimit,
+ })}
+ ,
+
+
+ );
+ }, [handleUpgrade, memberLimit, plan, t]);
+
return (
<>
{isOwner ? (
diff --git a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/style.css.ts b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/style.css.ts
index 988c1d32e8..a716ec60c5 100644
--- a/packages/frontend/core/src/components/affine/new-workspace-setting-detail/style.css.ts
+++ b/packages/frontend/core/src/components/affine/new-workspace-setting-detail/style.css.ts
@@ -182,3 +182,22 @@ export const workspaceLabel = style({
lineHeight: '20px',
whiteSpace: 'nowrap',
});
+
+export const goUpgrade = style({
+ fontSize: 'var(--affine-font-xs)',
+ color: 'var(--affine-text-emphasis-color)',
+ cursor: 'pointer',
+ marginLeft: '4px',
+ display: 'inline',
+});
+
+export const goUpgradeWrapper = style({
+ display: 'inline-flex',
+ alignItems: 'center',
+});
+
+export const arrowRight = style({
+ fontSize: '16px',
+ color: 'var(--affine-text-emphasis-color)',
+ cursor: 'pointer',
+});
diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json
index d8b0c50c4d..52fad8b4fb 100644
--- a/packages/frontend/i18n/src/resources/en.json
+++ b/packages/frontend/i18n/src/resources/en.json
@@ -711,5 +711,6 @@
"com.affine.payment.billing-setting.resume-subscription": "Resume",
"com.affine.payment.billing-setting.no-invoice": "There are no invoices to display.",
"com.affine.payment.billing-setting.paid": "Paid",
- "com.affine.payment.billing-setting.view-invoice": "View Invoice"
+ "com.affine.payment.billing-setting.view-invoice": "View Invoice",
+ "com.affine.payment.member.description": "Manage members here. {{planName}} Users can invite up to {{memberLimit}}"
}
From af2433426474923c58e1f95828d303f20717086f Mon Sep 17 00:00:00 2001
From: JimmFly
Date: Fri, 27 Oct 2023 15:49:32 +0800
Subject: [PATCH 22/27] feat(core): add upgrade success page (#4738)
---
.../server/src/modules/payment/resolver.ts | 3 +-
.../server/src/modules/payment/webhook.ts | 18 +--
.../core/src/pages/upgrade-success.css.ts | 93 ++++++++++++++++
.../core/src/pages/upgrade-success.tsx | 105 ++++++++++++++++++
packages/frontend/core/src/router.ts | 4 +
packages/frontend/i18n/src/resources/en.json | 9 ++
6 files changed, 213 insertions(+), 19 deletions(-)
create mode 100644 packages/frontend/core/src/pages/upgrade-success.css.ts
create mode 100644 packages/frontend/core/src/pages/upgrade-success.tsx
diff --git a/packages/backend/server/src/modules/payment/resolver.ts b/packages/backend/server/src/modules/payment/resolver.ts
index a7ef4b7cc4..653cd06466 100644
--- a/packages/backend/server/src/modules/payment/resolver.ts
+++ b/packages/backend/server/src/modules/payment/resolver.ts
@@ -194,8 +194,7 @@ export class SubscriptionResolver {
const session = await this.service.createCheckoutSession({
user,
recurring,
- // TODO: replace with frontend url
- redirectUrl: `${this.config.baseUrl}/api/stripe/success`,
+ redirectUrl: `${this.config.baseUrl}/upgrade-success`,
});
if (!session.url) {
diff --git a/packages/backend/server/src/modules/payment/webhook.ts b/packages/backend/server/src/modules/payment/webhook.ts
index 1fda0b6411..13f692132d 100644
--- a/packages/backend/server/src/modules/payment/webhook.ts
+++ b/packages/backend/server/src/modules/payment/webhook.ts
@@ -1,20 +1,16 @@
import type { RawBodyRequest } from '@nestjs/common';
import {
Controller,
- Get,
Logger,
NotAcceptableException,
Post,
Req,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
-import type { User } from '@prisma/client';
import type { Request } from 'express';
import Stripe from 'stripe';
import { Config } from '../../config';
-import { PrismaService } from '../../prisma';
-import { Auth, CurrentUser } from '../auth';
@Controller('/api/stripe')
export class StripeWebhook {
@@ -24,23 +20,11 @@ export class StripeWebhook {
constructor(
config: Config,
private readonly stripe: Stripe,
- private readonly event: EventEmitter2,
- private readonly db: PrismaService
+ private readonly event: EventEmitter2
) {
this.config = config.payment;
}
- // just for test
- @Auth()
- @Get('/success')
- async handleSuccess(@CurrentUser() user: User) {
- return this.db.userSubscription.findUnique({
- where: {
- userId: user.id,
- },
- });
- }
-
@Post('/webhook')
async handleWebhook(@Req() req: RawBodyRequest) {
// Check if webhook signing is configured.
diff --git a/packages/frontend/core/src/pages/upgrade-success.css.ts b/packages/frontend/core/src/pages/upgrade-success.css.ts
new file mode 100644
index 0000000000..4183c5ae7f
--- /dev/null
+++ b/packages/frontend/core/src/pages/upgrade-success.css.ts
@@ -0,0 +1,93 @@
+import { style } from '@vanilla-extract/css';
+
+export const root = style({
+ height: '100vh',
+ width: '100vw',
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ fontSize: 'var(--affine-font-base)',
+ position: 'relative',
+});
+
+export const affineLogo = style({
+ color: 'inherit',
+});
+
+export const topNav = style({
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ padding: '16px 120px',
+});
+
+export const topNavLinks = style({
+ display: 'flex',
+ columnGap: 4,
+});
+
+export const topNavLink = style({
+ color: 'var(--affine-text-primary-color)',
+ fontSize: 'var(--affine-font-sm)',
+ fontWeight: 500,
+ textDecoration: 'none',
+ padding: '4px 18px',
+});
+
+export const tryAgainLink = style({
+ color: 'var(--affine-link-color)',
+ fontWeight: 500,
+ textDecoration: 'none',
+ fontSize: 'var(--affine-font-sm)',
+});
+
+export const centerContent = style({
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ marginTop: 40,
+});
+
+export const prompt = style({
+ marginTop: 20,
+ marginBottom: 12,
+});
+
+export const body = style({
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ width: '100%',
+ flexWrap: 'wrap',
+ gap: '48px',
+ padding: '0 20px',
+});
+
+export const leftContainer = style({
+ display: 'flex',
+ flexDirection: 'column',
+ width: '548px',
+ gap: '28px',
+});
+export const leftContentTitle = style({
+ fontSize: 'var(--affine-font-title)',
+ fontWeight: 700,
+ minHeight: '44px',
+});
+export const leftContentText = style({
+ fontSize: 'var(--affine-font-base)',
+ fontWeight: 400,
+ lineHeight: '1.6',
+});
+
+export const mail = style({
+ color: 'var(--affine-link-color)',
+ textDecoration: 'none',
+ ':visited': {
+ color: 'var(--affine-link-color)',
+ },
+});
diff --git a/packages/frontend/core/src/pages/upgrade-success.tsx b/packages/frontend/core/src/pages/upgrade-success.tsx
new file mode 100644
index 0000000000..a8af870ead
--- /dev/null
+++ b/packages/frontend/core/src/pages/upgrade-success.tsx
@@ -0,0 +1,105 @@
+import { Empty } from '@affine/component';
+import { Trans } from '@affine/i18n';
+import { useAFFiNEI18N } from '@affine/i18n/hooks';
+import { Logo1Icon } from '@blocksuite/icons';
+import { Button } from '@toeverything/components/button';
+import { useCallback } from 'react';
+
+import { useNavigateHelper } from '../hooks/use-navigate-helper';
+import * as styles from './upgrade-success.css';
+
+export const UpgradeSuccess = () => {
+ const t = useAFFiNEI18N();
+
+ const openDownloadLink = useCallback(() => {
+ const url = `https://affine.pro/download`;
+ open(url, '_blank');
+ }, []);
+
+ const { jumpToIndex } = useNavigateHelper();
+ const openAffine = useCallback(() => {
+ jumpToIndex();
+ }, [jumpToIndex]);
+
+ return (
+
+
+
+
+
+
+
+
+
+ {t['com.affine.auth.open.affine.download-app']()}
+
+
+
+
+
+ {t['com.affine.payment.upgrade-success-page.title']()}
+
+
+ {t['com.affine.payment.upgrade-success-page.text']()}
+
+
+ ),
+ }}
+ />
+
+
+
+
+ {t['com.affine.other-page.nav.open-affine']()}
+
+
+
+
+
+
+ );
+};
+
+export const Component = () => {
+ return ;
+};
diff --git a/packages/frontend/core/src/router.ts b/packages/frontend/core/src/router.ts
index b067d9d7e8..944bbded39 100644
--- a/packages/frontend/core/src/router.ts
+++ b/packages/frontend/core/src/router.ts
@@ -52,6 +52,10 @@ export const routes = [
path: '/open-app/:action',
lazy: () => import('./pages/open-app'),
},
+ {
+ path: '/upgrade-success',
+ lazy: () => import('./pages/upgrade-success'),
+ },
{
path: '/desktop-signin',
lazy: () => import('./pages/desktop-signin'),
diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json
index 52fad8b4fb..813d159582 100644
--- a/packages/frontend/i18n/src/resources/en.json
+++ b/packages/frontend/i18n/src/resources/en.json
@@ -712,5 +712,14 @@
"com.affine.payment.billing-setting.no-invoice": "There are no invoices to display.",
"com.affine.payment.billing-setting.paid": "Paid",
"com.affine.payment.billing-setting.view-invoice": "View Invoice",
+ "com.affine.payment.upgrade-success-page.title": "Upgrade Successful!",
+ "com.affine.payment.upgrade-success-page.text": "Congratulations! Your AFFiNE account has been successfully upgraded to a Pro account.",
+ "com.affine.payment.upgrade-success-page.support": "If you have any questions, please contact our <1> customer support1>.",
+ "com.affine.other-page.nav.official-website": "Official Website",
+ "com.affine.other-page.nav.affine-community": "AFFiNE Community",
+ "com.affine.other-page.nav.blog": "Blog",
+ "com.affine.other-page.nav.contact-us": "Contact us",
+ "com.affine.other-page.nav.download-app": "Download App",
+ "com.affine.other-page.nav.open-affine": "Open AFFiNE",
"com.affine.payment.member.description": "Manage members here. {{planName}} Users can invite up to {{memberLimit}}"
}
From 87571a0879fc4f74d829a3058e3d4ad555960e2f Mon Sep 17 00:00:00 2001
From: Cats Juice
Date: Fri, 27 Oct 2023 15:59:41 +0800
Subject: [PATCH 23/27] chore(core): replace setting-modal sidebar icons
(#4742)
---
.../setting-modal/general-setting/icons.tsx | 41 +++++++++++++++++++
.../setting-modal/general-setting/index.tsx | 7 ++--
2 files changed, 44 insertions(+), 4 deletions(-)
create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/icons.tsx
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/icons.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/icons.tsx
new file mode 100644
index 0000000000..8933141a30
--- /dev/null
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/icons.tsx
@@ -0,0 +1,41 @@
+import type { SVGProps } from 'react';
+
+export const UpgradeIcon = ({ width, height }: SVGProps) => {
+ return (
+
+
+
+ );
+};
+
+export const PaymentIcon = ({ width, height }: SVGProps) => {
+ return (
+
+
+
+ );
+};
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx
index 241170a1d0..956d99bd5b 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/index.tsx
@@ -11,6 +11,7 @@ import { useCurrentLoginStatus } from '../../../../hooks/affine/use-current-logi
import { AboutAffine } from './about';
import { AppearanceSettings } from './appearance';
import { BillingSettings } from './billing';
+import { PaymentIcon, UpgradeIcon } from './icons';
import { AFFiNECloudPlans } from './plans';
import { Plugins } from './plugins';
import { Shortcuts } from './shortcuts';
@@ -52,8 +53,7 @@ export const useGeneralSettingList = (): GeneralSettingList => {
{
key: 'plans',
title: t['com.affine.payment.title'](),
- // TODO: icon
- icon: KeyboardIcon,
+ icon: UpgradeIcon,
testId: 'plans-panel-trigger',
},
@@ -75,8 +75,7 @@ export const useGeneralSettingList = (): GeneralSettingList => {
settings.splice(3, 0, {
key: 'billing',
title: t['com.affine.payment.billing-setting.title'](),
- // TODO: icon
- icon: KeyboardIcon,
+ icon: PaymentIcon,
testId: 'billing-panel-trigger',
});
}
From abbd8235aa3c3ff8054c7408f0f4a39de9b593ae Mon Sep 17 00:00:00 2001
From: Joooye_34
Date: Fri, 27 Oct 2023 16:59:13 +0800
Subject: [PATCH 24/27] chore(core): enable payment in canary (#4745)
---
packages/frontend/core/.webpack/runtime-config.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/frontend/core/.webpack/runtime-config.ts b/packages/frontend/core/.webpack/runtime-config.ts
index a93c354394..1e75ee07b8 100644
--- a/packages/frontend/core/.webpack/runtime-config.ts
+++ b/packages/frontend/core/.webpack/runtime-config.ts
@@ -66,7 +66,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
enableCloud: true,
enableCaptcha: true,
enableEnhanceShareMode: false,
- enablePayment: false,
+ enablePayment: true,
serverUrlPrefix: 'https://affine.fail',
editorFlags,
appVersion: packageJson.version,
From 8c194ab8b0253b215570f17a7dd4b90c58846326 Mon Sep 17 00:00:00 2001
From: Cats Juice
Date: Sat, 28 Oct 2023 14:12:25 +0800
Subject: [PATCH 25/27] feat(core): confirm before cancel in billing page
(#4749)
---
.../general-setting/billing/index.tsx | 49 ++++++++++---------
1 file changed, 27 insertions(+), 22 deletions(-)
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx
index a1eab2ab5d..ec4ef72362 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx
@@ -21,7 +21,7 @@ import { useMutation, useQuery } from '@affine/workspace/affine/gql';
import { ArrowRightSmallIcon } from '@blocksuite/icons';
import { Button, IconButton } from '@toeverything/components/button';
import { useSetAtom } from 'jotai';
-import { Suspense, useCallback, useMemo } from 'react';
+import { Suspense, useCallback, useMemo, useState } from 'react';
import { openSettingModalAtom } from '../../../../../atoms';
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
@@ -29,6 +29,7 @@ import {
type SubscriptionMutator,
useUserSubscription,
} from '../../../../../hooks/use-subscription';
+import { DowngradeModal } from '../plans/modals';
import * as styles from './style.css';
enum DescriptionI18NKey {
@@ -83,6 +84,19 @@ export const BillingSettings = () => {
const SubscriptionSettings = () => {
const [subscription, mutateSubscription] = useUserSubscription();
+ const { isMutating, trigger } = useMutation({
+ mutation: cancelSubscriptionMutation,
+ });
+ const [openCancelModal, setOpenCancelModal] = useState(false);
+
+ const cancel = useCallback(() => {
+ trigger(null, {
+ onSuccess: data => {
+ mutateSubscription(data.cancelSubscription);
+ },
+ });
+ }, [trigger, mutateSubscription]);
+
const { data: pricesQueryResult } = useQuery({
query: pricesQuery,
});
@@ -189,6 +203,8 @@ const SubscriptionSettings = () => {
) : (
(isMutating ? null : setOpenCancelModal(true))}
className="dangerous-setting"
name={t[
'com.affine.payment.billing-setting.cancel-subscription'
@@ -199,9 +215,14 @@ const SubscriptionSettings = () => {
cancelDate: new Date(subscription.end).toLocaleDateString(),
})}
>
-
+
)}
+ (isMutating ? null : cancel())}
+ onOpenChange={setOpenCancelModal}
+ />
>
)}
@@ -287,29 +308,13 @@ const ResumeSubscription = ({
);
};
-const CancelSubscription = ({
- onSubscriptionUpdate,
-}: {
- onSubscriptionUpdate: SubscriptionMutator;
-}) => {
- const { isMutating, trigger } = useMutation({
- mutation: cancelSubscriptionMutation,
- });
-
- const cancel = useCallback(() => {
- trigger(null, {
- onSuccess: data => {
- onSubscriptionUpdate(data.cancelSubscription);
- },
- });
- }, [trigger, onSubscriptionUpdate]);
-
+const CancelSubscription = ({ loading }: { loading?: boolean }) => {
return (
}
- disabled={isMutating}
- loading={isMutating}
- onClick={cancel /* TODO: popup confirmation modal instead */}
+ disabled={loading}
+ loading={loading}
/>
);
};
From 3798293d3e4d9f66f49b0a223b01be41252f401d Mon Sep 17 00:00:00 2001
From: DarkSky <25152247+darkskygit@users.noreply.github.com>
Date: Sun, 29 Oct 2023 11:33:21 -0500
Subject: [PATCH 26/27] fix: error handle in payment resolver (#4754)
---
.../server/src/modules/payment/resolver.ts | 42 +++++++++++++------
1 file changed, 29 insertions(+), 13 deletions(-)
diff --git a/packages/backend/server/src/modules/payment/resolver.ts b/packages/backend/server/src/modules/payment/resolver.ts
index 653cd06466..22d24c43a5 100644
--- a/packages/backend/server/src/modules/payment/resolver.ts
+++ b/packages/backend/server/src/modules/payment/resolver.ts
@@ -1,8 +1,4 @@
-import {
- BadGatewayException,
- ForbiddenException,
- InternalServerErrorException,
-} from '@nestjs/common';
+import { HttpStatus } from '@nestjs/common';
import {
Args,
Field,
@@ -16,6 +12,7 @@ import {
Resolver,
} from '@nestjs/graphql';
import type { User, UserInvoice, UserSubscription } from '@prisma/client';
+import { GraphQLError } from 'graphql';
import { groupBy } from 'lodash-es';
import { Config } from '../../config';
@@ -168,9 +165,12 @@ export class SubscriptionResolver {
);
if (!yearly || !monthly) {
- throw new BadGatewayException(
- 'The prices are not configured correctly'
- );
+ throw new GraphQLError('The prices are not configured correctly', {
+ extensions: {
+ status: HttpStatus[HttpStatus.BAD_GATEWAY],
+ code: HttpStatus.BAD_GATEWAY,
+ },
+ });
}
return {
@@ -198,9 +198,12 @@ export class SubscriptionResolver {
});
if (!session.url) {
- throw new InternalServerErrorException(
- 'Failed to create checkout session'
- );
+ throw new GraphQLError('Failed to create checkout session', {
+ extensions: {
+ status: HttpStatus[HttpStatus.BAD_GATEWAY],
+ code: HttpStatus.BAD_GATEWAY,
+ },
+ });
}
return session.url;
@@ -240,7 +243,15 @@ export class UserSubscriptionResolver {
@ResolveField(() => UserSubscriptionType, { nullable: true })
async subscription(@CurrentUser() me: User, @Parent() user: User) {
if (me.id !== user.id) {
- throw new ForbiddenException();
+ throw new GraphQLError(
+ 'You are not allowed to access this subscription',
+ {
+ extensions: {
+ status: HttpStatus[HttpStatus.FORBIDDEN],
+ code: HttpStatus.FORBIDDEN,
+ },
+ }
+ );
}
return this.db.userSubscription.findUnique({
@@ -259,7 +270,12 @@ export class UserSubscriptionResolver {
@Args('skip', { type: () => Int, nullable: true }) skip?: number
) {
if (me.id !== user.id) {
- throw new ForbiddenException();
+ throw new GraphQLError('You are not allowed to access this invoices', {
+ extensions: {
+ status: HttpStatus[HttpStatus.FORBIDDEN],
+ code: HttpStatus.FORBIDDEN,
+ },
+ });
}
return this.db.userInvoice.findMany({
From de9e7f97a4649be062a7c5d7245b6b128370a5c0 Mon Sep 17 00:00:00 2001
From: DarkSky <25152247+darkskygit@users.noreply.github.com>
Date: Mon, 30 Oct 2023 00:54:09 -0500
Subject: [PATCH 27/27] feat: add idempotent request support for payment apis
(#4753)
---
.../server/src/modules/payment/resolver.ts | 27 +++--
.../server/src/modules/payment/schedule.ts | 108 +++++++++++-------
.../server/src/modules/payment/service.ts | 100 +++++++++-------
packages/backend/server/src/schema.gql | 8 +-
.../general-setting/billing/index.tsx | 37 ++++--
.../general-setting/plans/plan-card.tsx | 52 ++++++---
.../src/graphql/cancel-subscription.gql | 4 +-
.../src/graphql/create-checkout-link.gql | 7 +-
.../frontend/graphql/src/graphql/index.ts | 19 +--
.../src/graphql/resume-subscription.gql | 4 +-
.../graphql/update-subscription-billing.gql | 10 +-
packages/frontend/graphql/src/schema.ts | 6 +-
12 files changed, 244 insertions(+), 138 deletions(-)
diff --git a/packages/backend/server/src/modules/payment/resolver.ts b/packages/backend/server/src/modules/payment/resolver.ts
index 22d24c43a5..f9950bfd9d 100644
--- a/packages/backend/server/src/modules/payment/resolver.ts
+++ b/packages/backend/server/src/modules/payment/resolver.ts
@@ -189,12 +189,14 @@ export class SubscriptionResolver {
async checkout(
@CurrentUser() user: User,
@Args({ name: 'recurring', type: () => SubscriptionRecurring })
- recurring: SubscriptionRecurring
+ recurring: SubscriptionRecurring,
+ @Args('idempotencyKey') idempotencyKey: string
) {
const session = await this.service.createCheckoutSession({
user,
recurring,
redirectUrl: `${this.config.baseUrl}/upgrade-success`,
+ idempotencyKey,
});
if (!session.url) {
@@ -217,22 +219,33 @@ export class SubscriptionResolver {
}
@Mutation(() => UserSubscriptionType)
- async cancelSubscription(@CurrentUser() user: User) {
- return this.service.cancelSubscription(user.id);
+ async cancelSubscription(
+ @CurrentUser() user: User,
+ @Args('idempotencyKey') idempotencyKey: string
+ ) {
+ return this.service.cancelSubscription(idempotencyKey, user.id);
}
@Mutation(() => UserSubscriptionType)
- async resumeSubscription(@CurrentUser() user: User) {
- return this.service.resumeCanceledSubscription(user.id);
+ async resumeSubscription(
+ @CurrentUser() user: User,
+ @Args('idempotencyKey') idempotencyKey: string
+ ) {
+ return this.service.resumeCanceledSubscription(idempotencyKey, user.id);
}
@Mutation(() => UserSubscriptionType)
async updateSubscriptionRecurring(
@CurrentUser() user: User,
@Args({ name: 'recurring', type: () => SubscriptionRecurring })
- recurring: SubscriptionRecurring
+ recurring: SubscriptionRecurring,
+ @Args('idempotencyKey') idempotencyKey: string
) {
- return this.service.updateSubscriptionRecurring(user.id, recurring);
+ return this.service.updateSubscriptionRecurring(
+ idempotencyKey,
+ user.id,
+ recurring
+ );
}
}
diff --git a/packages/backend/server/src/modules/payment/schedule.ts b/packages/backend/server/src/modules/payment/schedule.ts
index 423bdf6916..e27838e202 100644
--- a/packages/backend/server/src/modules/payment/schedule.ts
+++ b/packages/backend/server/src/modules/payment/schedule.ts
@@ -1,9 +1,10 @@
-import { Injectable } from '@nestjs/common';
+import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe';
@Injectable()
export class ScheduleManager {
private _schedule: Stripe.SubscriptionSchedule | null = null;
+ private readonly logger = new Logger(ScheduleManager.name);
constructor(private readonly stripe: Stripe) {}
@@ -50,7 +51,10 @@ export class ScheduleManager {
if (typeof schedule === 'string') {
const s = await this.stripe.subscriptionSchedules
.retrieve(schedule)
- .catch(() => undefined);
+ .catch(e => {
+ this.logger.error('Failed to retrieve subscription schedule', e);
+ return undefined;
+ });
return ScheduleManager.create(this.stripe, s);
} else {
@@ -58,7 +62,10 @@ export class ScheduleManager {
}
}
- async fromSubscription(subscription: string | Stripe.Subscription) {
+ async fromSubscription(
+ idempotencyKey: string,
+ subscription: string | Stripe.Subscription
+ ) {
if (typeof subscription === 'string') {
subscription = await this.stripe.subscriptions.retrieve(subscription, {
expand: ['schedule'],
@@ -68,9 +75,10 @@ export class ScheduleManager {
if (subscription.schedule) {
return await this.fromSchedule(subscription.schedule);
} else {
- const schedule = await this.stripe.subscriptionSchedules.create({
- from_subscription: subscription.id,
- });
+ const schedule = await this.stripe.subscriptionSchedules.create(
+ { from_subscription: subscription.id },
+ { idempotencyKey }
+ );
return await this.fromSchedule(schedule);
}
@@ -80,7 +88,7 @@ export class ScheduleManager {
* Cancel a subscription by marking schedule's end behavior to `cancel`.
* At the same time, the coming phase's price and coupon will be saved to metadata for later resuming to correction subscription.
*/
- async cancel() {
+ async cancel(idempotencyKey: string) {
if (!this._schedule) {
throw new Error('No schedule');
}
@@ -111,13 +119,17 @@ export class ScheduleManager {
};
}
- await this.stripe.subscriptionSchedules.update(this._schedule.id, {
- phases: [phases],
- end_behavior: 'cancel',
- });
+ await this.stripe.subscriptionSchedules.update(
+ this._schedule.id,
+ {
+ phases: [phases],
+ end_behavior: 'cancel',
+ },
+ { idempotencyKey }
+ );
}
- async resume() {
+ async resume(idempotencyKey: string) {
if (!this._schedule) {
throw new Error('No schedule');
}
@@ -156,21 +168,27 @@ export class ScheduleManager {
});
}
- await this.stripe.subscriptionSchedules.update(this._schedule.id, {
- phases: phases,
- end_behavior: 'release',
- });
+ await this.stripe.subscriptionSchedules.update(
+ this._schedule.id,
+ {
+ phases: phases,
+ end_behavior: 'release',
+ },
+ { idempotencyKey }
+ );
}
- async release() {
+ async release(idempotencyKey: string) {
if (!this._schedule) {
throw new Error('No schedule');
}
- await this.stripe.subscriptionSchedules.release(this._schedule.id);
+ await this.stripe.subscriptionSchedules.release(this._schedule.id, {
+ idempotencyKey,
+ });
}
- async update(price: string, coupon?: string) {
+ async update(idempotencyKey: string, price: string, coupon?: string) {
if (!this._schedule) {
throw new Error('No schedule');
}
@@ -184,31 +202,37 @@ export class ScheduleManager {
this.currentPhase.items[0].price === price &&
(!coupon || this.currentPhase.coupon === coupon)
) {
- await this.stripe.subscriptionSchedules.release(this._schedule.id);
+ await this.stripe.subscriptionSchedules.release(this._schedule.id, {
+ idempotencyKey,
+ });
this._schedule = null;
} else {
- await this.stripe.subscriptionSchedules.update(this._schedule.id, {
- phases: [
- {
- items: [
- {
- price: this.currentPhase.items[0].price as string,
- },
- ],
- start_date: this.currentPhase.start_date,
- end_date: this.currentPhase.end_date,
- },
- {
- items: [
- {
- price: price,
- quantity: 1,
- },
- ],
- coupon,
- },
- ],
- });
+ await this.stripe.subscriptionSchedules.update(
+ this._schedule.id,
+ {
+ phases: [
+ {
+ items: [
+ {
+ price: this.currentPhase.items[0].price as string,
+ },
+ ],
+ start_date: this.currentPhase.start_date,
+ end_date: this.currentPhase.end_date,
+ },
+ {
+ items: [
+ {
+ price: price,
+ quantity: 1,
+ },
+ ],
+ coupon,
+ },
+ ],
+ },
+ { idempotencyKey }
+ );
}
}
}
diff --git a/packages/backend/server/src/modules/payment/service.ts b/packages/backend/server/src/modules/payment/service.ts
index 91d98d2788..71b09f99ce 100644
--- a/packages/backend/server/src/modules/payment/service.ts
+++ b/packages/backend/server/src/modules/payment/service.ts
@@ -103,12 +103,14 @@ export class SubscriptionService {
user,
recurring,
redirectUrl,
+ idempotencyKey,
plan = SubscriptionPlan.Pro,
}: {
user: User;
plan?: SubscriptionPlan;
recurring: SubscriptionRecurring;
redirectUrl: string;
+ idempotencyKey: string;
}) {
const currentSubscription = await this.db.userSubscription.findUnique({
where: {
@@ -121,37 +123,43 @@ export class SubscriptionService {
}
const price = await this.getPrice(plan, recurring);
- const customer = await this.getOrCreateCustomer(user);
+ const customer = await this.getOrCreateCustomer(idempotencyKey, user);
const coupon = await this.getAvailableCoupon(user, CouponType.EarlyAccess);
- return await this.stripe.checkout.sessions.create({
- line_items: [
- {
- price,
- quantity: 1,
+ return await this.stripe.checkout.sessions.create(
+ {
+ line_items: [
+ {
+ price,
+ quantity: 1,
+ },
+ ],
+ tax_id_collection: {
+ enabled: true,
+ },
+ ...(coupon
+ ? {
+ discounts: [{ coupon }],
+ }
+ : {
+ allow_promotion_codes: true,
+ }),
+ mode: 'subscription',
+ success_url: redirectUrl,
+ customer: customer.stripeCustomerId,
+ customer_update: {
+ address: 'auto',
+ name: 'auto',
},
- ],
- tax_id_collection: {
- enabled: true,
},
- ...(coupon
- ? {
- discounts: [{ coupon }],
- }
- : {
- allow_promotion_codes: true,
- }),
- mode: 'subscription',
- success_url: redirectUrl,
- customer: customer.stripeCustomerId,
- customer_update: {
- address: 'auto',
- name: 'auto',
- },
- });
+ { idempotencyKey }
+ );
}
- async cancelSubscription(userId: string): Promise {
+ async cancelSubscription(
+ idempotencyKey: string,
+ userId: string
+ ): Promise {
const user = await this.db.user.findUnique({
where: {
id: userId,
@@ -174,7 +182,7 @@ export class SubscriptionService {
const manager = await this.scheduleManager.fromSchedule(
user.subscription.stripeScheduleId
);
- await manager.cancel();
+ await manager.cancel(idempotencyKey);
return this.saveSubscription(
user,
await this.stripe.subscriptions.retrieve(
@@ -187,15 +195,17 @@ export class SubscriptionService {
// see https://stripe.com/docs/billing/subscriptions/cancel
const subscription = await this.stripe.subscriptions.update(
user.subscription.stripeSubscriptionId,
- {
- cancel_at_period_end: true,
- }
+ { cancel_at_period_end: true },
+ { idempotencyKey }
);
return await this.saveSubscription(user, subscription);
}
}
- async resumeCanceledSubscription(userId: string): Promise {
+ async resumeCanceledSubscription(
+ idempotencyKey: string,
+ userId: string
+ ): Promise {
const user = await this.db.user.findUnique({
where: {
id: userId,
@@ -221,7 +231,7 @@ export class SubscriptionService {
const manager = await this.scheduleManager.fromSchedule(
user.subscription.stripeScheduleId
);
- await manager.resume();
+ await manager.resume(idempotencyKey);
return this.saveSubscription(
user,
await this.stripe.subscriptions.retrieve(
@@ -232,9 +242,8 @@ export class SubscriptionService {
} else {
const subscription = await this.stripe.subscriptions.update(
user.subscription.stripeSubscriptionId,
- {
- cancel_at_period_end: false,
- }
+ { cancel_at_period_end: false },
+ { idempotencyKey }
);
return await this.saveSubscription(user, subscription);
@@ -242,6 +251,7 @@ export class SubscriptionService {
}
async updateSubscriptionRecurring(
+ idempotencyKey: string,
userId: string,
recurring: SubscriptionRecurring
): Promise {
@@ -272,10 +282,12 @@ export class SubscriptionService {
);
const manager = await this.scheduleManager.fromSubscription(
+ idempotencyKey,
user.subscription.stripeSubscriptionId
);
await manager.update(
+ idempotencyKey,
price,
// if user is early access user, use early access coupon
manager.currentPhase?.coupon === CouponType.EarlyAccess ||
@@ -355,10 +367,16 @@ export class SubscriptionService {
// deal with early access user
if (stripeInvoice.discount?.coupon.id === CouponType.EarlyAccess) {
+ const idempotencyKey = stripeInvoice.id + '_earlyaccess';
const manager = await this.scheduleManager.fromSubscription(
+ idempotencyKey,
line.subscription as string
);
- await manager.update(line.price.id, CouponType.EarlyAccessRenew);
+ await manager.update(
+ idempotencyKey,
+ line.price.id,
+ CouponType.EarlyAccessRenew
+ );
}
}
@@ -530,7 +548,10 @@ export class SubscriptionService {
}
}
- private async getOrCreateCustomer(user: User): Promise {
+ private async getOrCreateCustomer(
+ idempotencyKey: string,
+ user: User
+ ): Promise {
const customer = await this.db.userStripeCustomer.findUnique({
where: {
userId: user.id,
@@ -550,9 +571,10 @@ export class SubscriptionService {
if (stripeCustomersList.data.length) {
stripeCustomer = stripeCustomersList.data[0];
} else {
- stripeCustomer = await this.stripe.customers.create({
- email: user.email,
- });
+ stripeCustomer = await this.stripe.customers.create(
+ { email: user.email },
+ { idempotencyKey }
+ );
}
return await this.db.userStripeCustomer.create({
diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql
index 5b62bdaa07..dc68b19d1a 100644
--- a/packages/backend/server/src/schema.gql
+++ b/packages/backend/server/src/schema.gql
@@ -278,13 +278,13 @@ type Mutation {
addToNewFeaturesWaitingList(type: NewFeaturesKind!, email: String!): AddToNewFeaturesWaitingList!
"""Create a subscription checkout link of stripe"""
- checkout(recurring: SubscriptionRecurring!): String!
+ checkout(recurring: SubscriptionRecurring!, idempotencyKey: String!): String!
"""Create a stripe customer portal to manage payment methods"""
createCustomerPortal: String!
- cancelSubscription: UserSubscription!
- resumeSubscription: UserSubscription!
- updateSubscriptionRecurring(recurring: SubscriptionRecurring!): UserSubscription!
+ cancelSubscription(idempotencyKey: String!): UserSubscription!
+ resumeSubscription(idempotencyKey: String!): UserSubscription!
+ updateSubscriptionRecurring(recurring: SubscriptionRecurring!, idempotencyKey: String!): UserSubscription!
}
"""The `Upload` scalar type represents a file upload."""
diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx
index ec4ef72362..bb417f3295 100644
--- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx
+++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx
@@ -21,6 +21,7 @@ import { useMutation, useQuery } from '@affine/workspace/affine/gql';
import { ArrowRightSmallIcon } from '@blocksuite/icons';
import { Button, IconButton } from '@toeverything/components/button';
import { useSetAtom } from 'jotai';
+import { nanoid } from 'nanoid';
import { Suspense, useCallback, useMemo, useState } from 'react';
import { openSettingModalAtom } from '../../../../../atoms';
@@ -89,13 +90,19 @@ const SubscriptionSettings = () => {
});
const [openCancelModal, setOpenCancelModal] = useState(false);
+ // allow replay request on network error until component unmount
+ const idempotencyKey = useMemo(() => nanoid(), []);
+
const cancel = useCallback(() => {
- trigger(null, {
- onSuccess: data => {
- mutateSubscription(data.cancelSubscription);
- },
- });
- }, [trigger, mutateSubscription]);
+ trigger(
+ { idempotencyKey },
+ {
+ onSuccess: data => {
+ mutateSubscription(data.cancelSubscription);
+ },
+ }
+ );
+ }, [trigger, idempotencyKey, mutateSubscription]);
const { data: pricesQueryResult } = useQuery({
query: pricesQuery,
@@ -288,13 +295,19 @@ const ResumeSubscription = ({
mutation: resumeSubscriptionMutation,
});
+ // allow replay request on network error until component unmount
+ const idempotencyKey = useMemo(() => nanoid(), []);
+
const resume = useCallback(() => {
- trigger(null, {
- onSuccess: data => {
- onSubscriptionUpdate(data.resumeSubscription);
- },
- });
- }, [trigger, onSubscriptionUpdate]);
+ trigger(
+ { idempotencyKey },
+ {
+ onSuccess: data => {
+ onSubscriptionUpdate(data.resumeSubscription);
+ },
+ }
+ );
+ }, [trigger, idempotencyKey, onSubscriptionUpdate]);
return (
nanoid(), []);
+
const downgrade = useCallback(() => {
- trigger(null, {
- onSuccess: data => {
- onSubscriptionUpdate(data.cancelSubscription);
- },
- });
- }, [trigger, onSubscriptionUpdate]);
+ trigger(
+ { idempotencyKey },
+ {
+ onSuccess: data => {
+ onSubscriptionUpdate(data.cancelSubscription);
+ },
+ }
+ );
+ }, [trigger, idempotencyKey, onSubscriptionUpdate]);
const tooltipContent = disabled
? t['com.affine.payment.downgraded-tooltip']()
@@ -365,6 +373,9 @@ const Upgrade = ({
const newTabRef = useRef(null);
+ // allow replay request on network error until component unmount
+ const idempotencyKey = useMemo(() => nanoid(), []);
+
const onClose = useCallback(() => {
newTabRef.current = null;
onSubscriptionUpdate();
@@ -381,7 +392,7 @@ const Upgrade = ({
newTabRef.current.focus();
} else {
trigger(
- { recurring },
+ { recurring, idempotencyKey },
{
onSuccess: data => {
// FIXME: safari prevents from opening new tab by window api
@@ -401,7 +412,7 @@ const Upgrade = ({
}
);
}
- }, [trigger, recurring, onClose, openPaymentDisableModal]);
+ }, [openPaymentDisableModal, trigger, recurring, idempotencyKey, onClose]);
useEffect(() => {
return () => {
@@ -446,16 +457,19 @@ const ChangeRecurring = ({
mutation: updateSubscriptionMutation,
});
+ // allow replay request on network error until component unmount
+ const idempotencyKey = useMemo(() => nanoid(), []);
+
const change = useCallback(() => {
trigger(
- { recurring: to },
+ { recurring: to, idempotencyKey },
{
onSuccess: data => {
onSubscriptionUpdate(data.updateSubscriptionRecurring);
},
}
);
- }, [trigger, onSubscriptionUpdate, to]);
+ }, [trigger, to, idempotencyKey, onSubscriptionUpdate]);
const changeCurringContent = (
@@ -523,13 +537,19 @@ const ResumeAction = ({
mutation: resumeSubscriptionMutation,
});
+ // allow replay request on network error until component unmount
+ const idempotencyKey = useMemo(() => nanoid(), []);
+
const resume = useCallback(() => {
- trigger(null, {
- onSuccess: data => {
- onSubscriptionUpdate(data.resumeSubscription);
- },
- });
- }, [trigger, onSubscriptionUpdate]);
+ trigger(
+ { idempotencyKey },
+ {
+ onSuccess: data => {
+ onSubscriptionUpdate(data.resumeSubscription);
+ },
+ }
+ );
+ }, [trigger, idempotencyKey, onSubscriptionUpdate]);
return (
<>
diff --git a/packages/frontend/graphql/src/graphql/cancel-subscription.gql b/packages/frontend/graphql/src/graphql/cancel-subscription.gql
index 128e206781..6c791d909c 100644
--- a/packages/frontend/graphql/src/graphql/cancel-subscription.gql
+++ b/packages/frontend/graphql/src/graphql/cancel-subscription.gql
@@ -1,5 +1,5 @@
-mutation cancelSubscription {
- cancelSubscription {
+mutation cancelSubscription($idempotencyKey: String!) {
+ cancelSubscription(idempotencyKey: $idempotencyKey) {
id
status
nextBillAt
diff --git a/packages/frontend/graphql/src/graphql/create-checkout-link.gql b/packages/frontend/graphql/src/graphql/create-checkout-link.gql
index b4e4704e97..5ab82000bb 100644
--- a/packages/frontend/graphql/src/graphql/create-checkout-link.gql
+++ b/packages/frontend/graphql/src/graphql/create-checkout-link.gql
@@ -1,3 +1,6 @@
-mutation checkout($recurring: SubscriptionRecurring!) {
- checkout(recurring: $recurring)
+mutation checkout(
+ $recurring: SubscriptionRecurring!
+ $idempotencyKey: String!
+) {
+ checkout(recurring: $recurring, idempotencyKey: $idempotencyKey)
}
diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts
index 5817251585..b5e39f3166 100644
--- a/packages/frontend/graphql/src/graphql/index.ts
+++ b/packages/frontend/graphql/src/graphql/index.ts
@@ -85,8 +85,8 @@ export const cancelSubscriptionMutation = {
definitionName: 'cancelSubscription',
containsFile: false,
query: `
-mutation cancelSubscription {
- cancelSubscription {
+mutation cancelSubscription($idempotencyKey: String!) {
+ cancelSubscription(idempotencyKey: $idempotencyKey) {
id
status
nextBillAt
@@ -133,8 +133,8 @@ export const checkoutMutation = {
definitionName: 'checkout',
containsFile: false,
query: `
-mutation checkout($recurring: SubscriptionRecurring!) {
- checkout(recurring: $recurring)
+mutation checkout($recurring: SubscriptionRecurring!, $idempotencyKey: String!) {
+ checkout(recurring: $recurring, idempotencyKey: $idempotencyKey)
}`,
};
@@ -434,8 +434,8 @@ export const resumeSubscriptionMutation = {
definitionName: 'resumeSubscription',
containsFile: false,
query: `
-mutation resumeSubscription {
- resumeSubscription {
+mutation resumeSubscription($idempotencyKey: String!) {
+ resumeSubscription(idempotencyKey: $idempotencyKey) {
id
status
nextBillAt
@@ -593,8 +593,11 @@ export const updateSubscriptionMutation = {
definitionName: 'updateSubscriptionRecurring',
containsFile: false,
query: `
-mutation updateSubscription($recurring: SubscriptionRecurring!) {
- updateSubscriptionRecurring(recurring: $recurring) {
+mutation updateSubscription($recurring: SubscriptionRecurring!, $idempotencyKey: String!) {
+ updateSubscriptionRecurring(
+ recurring: $recurring
+ idempotencyKey: $idempotencyKey
+ ) {
id
plan
recurring
diff --git a/packages/frontend/graphql/src/graphql/resume-subscription.gql b/packages/frontend/graphql/src/graphql/resume-subscription.gql
index b56cb767bf..c060e25059 100644
--- a/packages/frontend/graphql/src/graphql/resume-subscription.gql
+++ b/packages/frontend/graphql/src/graphql/resume-subscription.gql
@@ -1,5 +1,5 @@
-mutation resumeSubscription {
- resumeSubscription {
+mutation resumeSubscription($idempotencyKey: String!) {
+ resumeSubscription(idempotencyKey: $idempotencyKey) {
id
status
nextBillAt
diff --git a/packages/frontend/graphql/src/graphql/update-subscription-billing.gql b/packages/frontend/graphql/src/graphql/update-subscription-billing.gql
index 1464d399d6..1957efbfa3 100644
--- a/packages/frontend/graphql/src/graphql/update-subscription-billing.gql
+++ b/packages/frontend/graphql/src/graphql/update-subscription-billing.gql
@@ -1,5 +1,11 @@
-mutation updateSubscription($recurring: SubscriptionRecurring!) {
- updateSubscriptionRecurring(recurring: $recurring) {
+mutation updateSubscription(
+ $recurring: SubscriptionRecurring!
+ $idempotencyKey: String!
+) {
+ updateSubscriptionRecurring(
+ recurring: $recurring
+ idempotencyKey: $idempotencyKey
+ ) {
id
plan
recurring
diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts
index cea6064754..07e606e7f2 100644
--- a/packages/frontend/graphql/src/schema.ts
+++ b/packages/frontend/graphql/src/schema.ts
@@ -131,7 +131,7 @@ export type AllBlobSizesQuery = {
};
export type CancelSubscriptionMutationVariables = Exact<{
- [key: string]: never;
+ idempotencyKey: Scalars['String']['input'];
}>;
export type CancelSubscriptionMutation = {
@@ -178,6 +178,7 @@ export type ChangePasswordMutation = {
export type CheckoutMutationVariables = Exact<{
recurring: SubscriptionRecurring;
+ idempotencyKey: Scalars['String']['input'];
}>;
export type CheckoutMutation = { __typename?: 'Mutation'; checkout: string };
@@ -416,7 +417,7 @@ export type RemoveAvatarMutation = {
};
export type ResumeSubscriptionMutationVariables = Exact<{
- [key: string]: never;
+ idempotencyKey: Scalars['String']['input'];
}>;
export type ResumeSubscriptionMutation = {
@@ -558,6 +559,7 @@ export type SubscriptionQuery = {
export type UpdateSubscriptionMutationVariables = Exact<{
recurring: SubscriptionRecurring;
+ idempotencyKey: Scalars['String']['input'];
}>;
export type UpdateSubscriptionMutation = {