feat(server): support team workspace subscription (#8919)

close AF-1724, AF-1722
This commit is contained in:
forehalo
2024-12-05 08:31:01 +00:00
parent 4055e3aa67
commit 5bf8ed1095
26 changed files with 2208 additions and 785 deletions

View File

@@ -0,0 +1,53 @@
-- DropForeignKey
ALTER TABLE "user_invoices" DROP CONSTRAINT "user_invoices_user_id_fkey";
-- DropForeignKey
ALTER TABLE "user_subscriptions" DROP CONSTRAINT "user_subscriptions_user_id_fkey";
-- CreateTable
CREATE TABLE "subscriptions" (
"id" SERIAL NOT NULL,
"target_id" VARCHAR NOT NULL,
"plan" VARCHAR(20) NOT NULL,
"recurring" VARCHAR(20) NOT NULL,
"variant" VARCHAR(20),
"quantity" INTEGER NOT NULL DEFAULT 1,
"stripe_subscription_id" TEXT,
"stripe_schedule_id" VARCHAR,
"status" VARCHAR(20) NOT NULL,
"start" TIMESTAMPTZ(3) NOT NULL,
"end" TIMESTAMPTZ(3),
"next_bill_at" TIMESTAMPTZ(3),
"canceled_at" TIMESTAMPTZ(3),
"trial_start" TIMESTAMPTZ(3),
"trial_end" TIMESTAMPTZ(3),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "invoices" (
"stripe_invoice_id" TEXT NOT NULL,
"target_id" VARCHAR NOT NULL,
"currency" VARCHAR(3) NOT NULL,
"amount" INTEGER NOT NULL,
"status" VARCHAR(20) NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
"reason" VARCHAR,
"last_payment_error" TEXT,
"link" TEXT,
CONSTRAINT "invoices_pkey" PRIMARY KEY ("stripe_invoice_id")
);
-- CreateIndex
CREATE UNIQUE INDEX "subscriptions_stripe_subscription_id_key" ON "subscriptions"("stripe_subscription_id");
-- CreateIndex
CREATE UNIQUE INDEX "subscriptions_target_id_plan_key" ON "subscriptions"("target_id", "plan");
-- CreateIndex
CREATE INDEX "invoices_target_id_idx" ON "invoices"("target_id");

View File

@@ -23,9 +23,7 @@ model User {
registered Boolean @default(true) registered Boolean @default(true)
features UserFeature[] features UserFeature[]
customer UserStripeCustomer? userStripeCustomer UserStripeCustomer?
subscriptions UserSubscription[]
invoices UserInvoice[]
workspacePermissions WorkspaceUserPermission[] workspacePermissions WorkspaceUserPermission[]
pagePermissions WorkspacePageUserPermission[] pagePermissions WorkspacePageUserPermission[]
connectedAccounts ConnectedAccount[] connectedAccounts ConnectedAccount[]
@@ -318,77 +316,6 @@ model SnapshotHistory {
@@map("snapshot_histories") @@map("snapshot_histories")
} }
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(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("user_stripe_customers")
}
model UserSubscription {
id Int @id @default(autoincrement()) @db.Integer
userId String @map("user_id") @db.VarChar
plan String @db.VarChar(20)
// yearly/monthly/lifetime
recurring String @db.VarChar(20)
// onetime subscription or anything else
variant String? @db.VarChar(20)
// subscription.id, null for linefetime payment or one time payment subscription
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(3)
// subscription.current_period_end, null for lifetime payment
end DateTime? @map("end") @db.Timestamptz(3)
// subscription.billing_cycle_anchor
nextBillAt DateTime? @map("next_bill_at") @db.Timestamptz(3)
// subscription.canceled_at
canceledAt DateTime? @map("canceled_at") @db.Timestamptz(3)
// subscription.trial_start
trialStart DateTime? @map("trial_start") @db.Timestamptz(3)
// subscription.trial_end
trialEnd DateTime? @map("trial_end") @db.Timestamptz(3)
stripeScheduleId String? @map("stripe_schedule_id") @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, plan])
@@map("user_subscriptions")
}
model UserInvoice {
id Int @id @default(autoincrement()) @db.Integer
userId String @map("user_id") @db.VarChar
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)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
// billing reason
reason String? @db.VarChar
lastPaymentError String? @map("last_payment_error") @db.Text
// stripe hosted invoice link
link String? @db.Text
// @deprecated
plan String? @db.VarChar(20)
// @deprecated
recurring String? @db.VarChar(20)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("user_invoices")
}
enum AiPromptRole { enum AiPromptRole {
system system
assistant assistant
@@ -503,3 +430,124 @@ model RuntimeConfig {
@@unique([module, key]) @@unique([module, key])
@@map("app_runtime_settings") @@map("app_runtime_settings")
} }
model DeprecatedUserSubscription {
id Int @id @default(autoincrement()) @db.Integer
userId String @map("user_id") @db.VarChar
plan String @db.VarChar(20)
// yearly/monthly/lifetime
recurring String @db.VarChar(20)
// onetime subscription or anything else
variant String? @db.VarChar(20)
// subscription.id, null for lifetime payment or one time payment subscription
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(3)
// subscription.current_period_end, null for lifetime payment
end DateTime? @map("end") @db.Timestamptz(3)
// subscription.billing_cycle_anchor
nextBillAt DateTime? @map("next_bill_at") @db.Timestamptz(3)
// subscription.canceled_at
canceledAt DateTime? @map("canceled_at") @db.Timestamptz(3)
// subscription.trial_start
trialStart DateTime? @map("trial_start") @db.Timestamptz(3)
// subscription.trial_end
trialEnd DateTime? @map("trial_end") @db.Timestamptz(3)
stripeScheduleId String? @map("stripe_schedule_id") @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@unique([userId, plan])
@@map("user_subscriptions")
}
model DeprecatedUserInvoice {
id Int @id @default(autoincrement()) @db.Integer
userId String @map("user_id") @db.VarChar
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)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
// billing reason
reason String? @db.VarChar
lastPaymentError String? @map("last_payment_error") @db.Text
// stripe hosted invoice link
link String? @db.Text
// @deprecated
plan String? @db.VarChar(20)
// @deprecated
recurring String? @db.VarChar(20)
@@index([userId])
@@map("user_invoices")
}
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(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("user_stripe_customers")
}
model Subscription {
id Int @id @default(autoincrement()) @db.Integer
targetId String @map("target_id") @db.VarChar
plan String @db.VarChar(20)
// yearly/monthly/lifetime
recurring String @db.VarChar(20)
// onetime subscription or anything else
variant String? @db.VarChar(20)
quantity Int @default(1) @db.Integer
// subscription.id, null for lifetime payment or one time payment subscription
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
// stripe schedule id
stripeScheduleId String? @map("stripe_schedule_id") @db.VarChar
// subscription.status, active/past_due/canceled/unpaid...
status String @db.VarChar(20)
// subscription.current_period_start
start DateTime @map("start") @db.Timestamptz(3)
// subscription.current_period_end, null for lifetime payment
end DateTime? @map("end") @db.Timestamptz(3)
// subscription.billing_cycle_anchor
nextBillAt DateTime? @map("next_bill_at") @db.Timestamptz(3)
// subscription.canceled_at
canceledAt DateTime? @map("canceled_at") @db.Timestamptz(3)
// subscription.trial_start
trialStart DateTime? @map("trial_start") @db.Timestamptz(3)
// subscription.trial_end
trialEnd DateTime? @map("trial_end") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
@@unique([targetId, plan])
@@map("subscriptions")
}
model Invoice {
stripeInvoiceId String @id @map("stripe_invoice_id")
targetId String @map("target_id") @db.VarChar
currency String @db.VarChar(3)
// CNY 12.50 stored as 1250
amount Int @db.Integer
status String @db.VarChar(20)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
// billing reason
reason String? @db.VarChar
lastPaymentError String? @map("last_payment_error") @db.Text
// stripe hosted invoice link
link String? @db.Text
@@index([targetId])
@@map("invoices")
}

View File

@@ -5,7 +5,6 @@ import {
Int, Int,
Mutation, Mutation,
Query, Query,
ResolveField,
Resolver, Resolver,
} from '@nestjs/graphql'; } from '@nestjs/graphql';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
@@ -37,7 +36,6 @@ import {
@Resolver(() => UserType) @Resolver(() => UserType)
export class UserResolver { export class UserResolver {
constructor( constructor(
private readonly prisma: PrismaClient,
private readonly storage: AvatarStorage, private readonly storage: AvatarStorage,
private readonly users: UserService private readonly users: UserService
) {} ) {}
@@ -72,16 +70,6 @@ export class UserResolver {
}; };
} }
@ResolveField(() => Int, {
name: 'invoiceCount',
description: 'Get user invoice count',
})
async invoiceCount(@CurrentUser() user: CurrentUser) {
return this.prisma.userInvoice.count({
where: { userId: user.id },
});
}
@Mutation(() => UserType, { @Mutation(() => UserType, {
name: 'uploadAvatar', name: 'uploadAvatar',
description: 'Upload user avatar', description: 'Upload user avatar',

View File

@@ -37,4 +37,4 @@ import {
}) })
export class WorkspaceModule {} export class WorkspaceModule {}
export type { InvitationType, WorkspaceType } from './types'; export { InvitationType, WorkspaceType } from './types';

View File

@@ -0,0 +1,29 @@
import { PrismaClient } from '@prisma/client';
import { loop } from './utils/loop';
export class UniversalSubscription1733125339942 {
// do the migration
static async up(db: PrismaClient) {
await loop(async (offset, take) => {
const oldSubscriptions = await db.deprecatedUserSubscription.findMany({
skip: offset,
take,
});
await db.subscription.createMany({
data: oldSubscriptions.map(s => ({
targetId: s.userId,
...s,
})),
});
return oldSubscriptions.length;
}, 50);
}
// revert the migration
static async down(_db: PrismaClient) {
// noop
}
}

View File

@@ -36,3 +36,5 @@ export class ConfigModule {
}; };
}; };
} }
export { Runtime };

View File

@@ -412,15 +412,28 @@ export const USER_FRIENDLY_ERRORS = {
}, },
// Subscription Errors // Subscription Errors
unsupported_subscription_plan: {
type: 'invalid_input',
args: { plan: 'string' },
message: ({ plan }) => `Unsupported subscription plan: ${plan}.`,
},
failed_to_checkout: { failed_to_checkout: {
type: 'internal_server_error', type: 'internal_server_error',
message: 'Failed to create checkout session.', message: 'Failed to create checkout session.',
}, },
invalid_checkout_parameters: {
type: 'invalid_input',
message: 'Invalid checkout parameters provided.',
},
subscription_already_exists: { subscription_already_exists: {
type: 'resource_already_exists', type: 'resource_already_exists',
args: { plan: 'string' }, args: { plan: 'string' },
message: ({ plan }) => `You have already subscribed to the ${plan} plan.`, message: ({ plan }) => `You have already subscribed to the ${plan} plan.`,
}, },
invalid_subscription_parameters: {
type: 'invalid_input',
message: 'Invalid subscription parameters provided.',
},
subscription_not_exists: { subscription_not_exists: {
type: 'resource_not_found', type: 'resource_not_found',
args: { plan: 'string' }, args: { plan: 'string' },
@@ -430,6 +443,10 @@ export const USER_FRIENDLY_ERRORS = {
type: 'action_forbidden', type: 'action_forbidden',
message: 'Your subscription has already been canceled.', message: 'Your subscription has already been canceled.',
}, },
subscription_has_not_been_canceled: {
type: 'action_forbidden',
message: 'Your subscription has not been canceled.',
},
subscription_expired: { subscription_expired: {
type: 'action_forbidden', type: 'action_forbidden',
message: 'Your subscription has expired.', message: 'Your subscription has expired.',
@@ -453,6 +470,14 @@ export const USER_FRIENDLY_ERRORS = {
type: 'action_forbidden', type: 'action_forbidden',
message: 'You cannot update an onetime payment subscription.', message: 'You cannot update an onetime payment subscription.',
}, },
workspace_id_required_for_team_subscription: {
type: 'invalid_input',
message: 'A workspace is required to checkout for team subscription.',
},
workspace_id_required_to_update_team_subscription: {
type: 'invalid_input',
message: 'Workspace id is required to update team subscription.',
},
// Copilot errors // Copilot errors
copilot_session_not_found: { copilot_session_not_found: {

View File

@@ -328,12 +328,28 @@ export class FailedToUpsertSnapshot extends UserFriendlyError {
super('internal_server_error', 'failed_to_upsert_snapshot', message); super('internal_server_error', 'failed_to_upsert_snapshot', message);
} }
} }
@ObjectType()
class UnsupportedSubscriptionPlanDataType {
@Field() plan!: string
}
export class UnsupportedSubscriptionPlan extends UserFriendlyError {
constructor(args: UnsupportedSubscriptionPlanDataType, message?: string | ((args: UnsupportedSubscriptionPlanDataType) => string)) {
super('invalid_input', 'unsupported_subscription_plan', message, args);
}
}
export class FailedToCheckout extends UserFriendlyError { export class FailedToCheckout extends UserFriendlyError {
constructor(message?: string) { constructor(message?: string) {
super('internal_server_error', 'failed_to_checkout', message); super('internal_server_error', 'failed_to_checkout', message);
} }
} }
export class InvalidCheckoutParameters extends UserFriendlyError {
constructor(message?: string) {
super('invalid_input', 'invalid_checkout_parameters', message);
}
}
@ObjectType() @ObjectType()
class SubscriptionAlreadyExistsDataType { class SubscriptionAlreadyExistsDataType {
@Field() plan!: string @Field() plan!: string
@@ -344,6 +360,12 @@ export class SubscriptionAlreadyExists extends UserFriendlyError {
super('resource_already_exists', 'subscription_already_exists', message, args); super('resource_already_exists', 'subscription_already_exists', message, args);
} }
} }
export class InvalidSubscriptionParameters extends UserFriendlyError {
constructor(message?: string) {
super('invalid_input', 'invalid_subscription_parameters', message);
}
}
@ObjectType() @ObjectType()
class SubscriptionNotExistsDataType { class SubscriptionNotExistsDataType {
@Field() plan!: string @Field() plan!: string
@@ -361,6 +383,12 @@ export class SubscriptionHasBeenCanceled extends UserFriendlyError {
} }
} }
export class SubscriptionHasNotBeenCanceled extends UserFriendlyError {
constructor(message?: string) {
super('action_forbidden', 'subscription_has_not_been_canceled', message);
}
}
export class SubscriptionExpired extends UserFriendlyError { export class SubscriptionExpired extends UserFriendlyError {
constructor(message?: string) { constructor(message?: string) {
super('action_forbidden', 'subscription_expired', message); super('action_forbidden', 'subscription_expired', message);
@@ -400,6 +428,18 @@ export class CantUpdateOnetimePaymentSubscription extends UserFriendlyError {
} }
} }
export class WorkspaceIdRequiredForTeamSubscription extends UserFriendlyError {
constructor(message?: string) {
super('invalid_input', 'workspace_id_required_for_team_subscription', message);
}
}
export class WorkspaceIdRequiredToUpdateTeamSubscription extends UserFriendlyError {
constructor(message?: string) {
super('invalid_input', 'workspace_id_required_to_update_team_subscription', message);
}
}
export class CopilotSessionNotFound extends UserFriendlyError { export class CopilotSessionNotFound extends UserFriendlyError {
constructor(message?: string) { constructor(message?: string) {
super('resource_not_found', 'copilot_session_not_found', message); super('resource_not_found', 'copilot_session_not_found', message);
@@ -587,15 +627,21 @@ export enum ErrorNames {
PAGE_IS_NOT_PUBLIC, PAGE_IS_NOT_PUBLIC,
FAILED_TO_SAVE_UPDATES, FAILED_TO_SAVE_UPDATES,
FAILED_TO_UPSERT_SNAPSHOT, FAILED_TO_UPSERT_SNAPSHOT,
UNSUPPORTED_SUBSCRIPTION_PLAN,
FAILED_TO_CHECKOUT, FAILED_TO_CHECKOUT,
INVALID_CHECKOUT_PARAMETERS,
SUBSCRIPTION_ALREADY_EXISTS, SUBSCRIPTION_ALREADY_EXISTS,
INVALID_SUBSCRIPTION_PARAMETERS,
SUBSCRIPTION_NOT_EXISTS, SUBSCRIPTION_NOT_EXISTS,
SUBSCRIPTION_HAS_BEEN_CANCELED, SUBSCRIPTION_HAS_BEEN_CANCELED,
SUBSCRIPTION_HAS_NOT_BEEN_CANCELED,
SUBSCRIPTION_EXPIRED, SUBSCRIPTION_EXPIRED,
SAME_SUBSCRIPTION_RECURRING, SAME_SUBSCRIPTION_RECURRING,
CUSTOMER_PORTAL_CREATE_FAILED, CUSTOMER_PORTAL_CREATE_FAILED,
SUBSCRIPTION_PLAN_NOT_FOUND, SUBSCRIPTION_PLAN_NOT_FOUND,
CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION, CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION,
WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION,
WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION,
COPILOT_SESSION_NOT_FOUND, COPILOT_SESSION_NOT_FOUND,
COPILOT_SESSION_DELETED, COPILOT_SESSION_DELETED,
NO_COPILOT_PROVIDER_AVAILABLE, NO_COPILOT_PROVIDER_AVAILABLE,
@@ -624,5 +670,5 @@ registerEnumType(ErrorNames, {
export const ErrorDataUnionType = createUnionType({ export const ErrorDataUnionType = createUnionType({
name: 'ErrorDataUnion', name: 'ErrorDataUnion',
types: () => types: () =>
[UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, SpaceNotFoundDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, DocNotFoundDataType, DocAccessDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType] as const, [UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, SpaceNotFoundDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, DocNotFoundDataType, DocAccessDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType] as const,
}); });

View File

@@ -29,6 +29,6 @@ defineStartupConfig('plugins.payment', {});
defineRuntimeConfig('plugins.payment', { defineRuntimeConfig('plugins.payment', {
showLifetimePrice: { showLifetimePrice: {
desc: 'Whether enable lifetime price and allow user to pay for it.', desc: 'Whether enable lifetime price and allow user to pay for it.',
default: false, default: true,
}, },
}); });

View File

@@ -19,7 +19,7 @@ export class SubscriptionCronJobs {
@Cron(CronExpression.EVERY_HOUR) @Cron(CronExpression.EVERY_HOUR)
async cleanExpiredOnetimeSubscriptions() { async cleanExpiredOnetimeSubscriptions() {
const subscriptions = await this.db.userSubscription.findMany({ const subscriptions = await this.db.subscription.findMany({
where: { where: {
variant: SubscriptionVariant.Onetime, variant: SubscriptionVariant.Onetime,
end: { end: {
@@ -30,7 +30,7 @@ export class SubscriptionCronJobs {
for (const subscription of subscriptions) { for (const subscription of subscriptions) {
this.event.emit('user.subscription.canceled', { this.event.emit('user.subscription.canceled', {
userId: subscription.userId, userId: subscription.targetId,
plan: subscription.plan as SubscriptionPlan, plan: subscription.plan as SubscriptionPlan,
recurring: subscription.variant as SubscriptionRecurring, recurring: subscription.variant as SubscriptionRecurring,
}); });
@@ -42,10 +42,10 @@ export class SubscriptionCronJobs {
userId, userId,
plan, plan,
}: EventPayload<'user.subscription.canceled'>) { }: EventPayload<'user.subscription.canceled'>) {
await this.db.userSubscription.delete({ await this.db.subscription.delete({
where: { where: {
userId_plan: { targetId_plan: {
userId, targetId: userId,
plan, plan,
}, },
}, },

View File

@@ -2,19 +2,27 @@ import './config';
import { ServerFeature } from '../../core/config'; import { ServerFeature } from '../../core/config';
import { FeatureModule } from '../../core/features'; import { FeatureModule } from '../../core/features';
import { PermissionModule } from '../../core/permission';
import { UserModule } from '../../core/user'; import { UserModule } from '../../core/user';
import { Plugin } from '../registry'; import { Plugin } from '../registry';
import { StripeWebhookController } from './controller'; import { StripeWebhookController } from './controller';
import { SubscriptionCronJobs } from './cron'; import { SubscriptionCronJobs } from './cron';
import { UserSubscriptionManager } from './manager'; import {
import { SubscriptionResolver, UserSubscriptionResolver } from './resolver'; UserSubscriptionManager,
WorkspaceSubscriptionManager,
} from './manager';
import {
SubscriptionResolver,
UserSubscriptionResolver,
WorkspaceSubscriptionResolver,
} from './resolver';
import { SubscriptionService } from './service'; import { SubscriptionService } from './service';
import { StripeProvider } from './stripe'; import { StripeProvider } from './stripe';
import { StripeWebhook } from './webhook'; import { StripeWebhook } from './webhook';
@Plugin({ @Plugin({
name: 'payment', name: 'payment',
imports: [FeatureModule, UserModule], imports: [FeatureModule, UserModule, PermissionModule],
providers: [ providers: [
StripeProvider, StripeProvider,
SubscriptionService, SubscriptionService,
@@ -22,7 +30,9 @@ import { StripeWebhook } from './webhook';
UserSubscriptionResolver, UserSubscriptionResolver,
StripeWebhook, StripeWebhook,
UserSubscriptionManager, UserSubscriptionManager,
WorkspaceSubscriptionManager,
SubscriptionCronJobs, SubscriptionCronJobs,
WorkspaceSubscriptionResolver,
], ],
controllers: [StripeWebhookController], controllers: [StripeWebhookController],
requires: [ requires: [

View File

@@ -1,13 +1,23 @@
import { UserStripeCustomer } from '@prisma/client'; import { PrismaClient, UserStripeCustomer } from '@prisma/client';
import Stripe from 'stripe';
import { z } from 'zod';
import { UserNotFound } from '../../../fundamentals';
import { ScheduleManager } from '../schedule';
import { import {
encodeLookupKey,
KnownStripeInvoice,
KnownStripePrice, KnownStripePrice,
KnownStripeSubscription, KnownStripeSubscription,
LookupKey,
SubscriptionPlan, SubscriptionPlan,
SubscriptionRecurring, SubscriptionRecurring,
SubscriptionVariant,
} from '../types'; } from '../types';
export interface Subscription { export interface Subscription {
stripeSubscriptionId: string | null;
stripeScheduleId: string | null;
status: string; status: string;
plan: string; plan: string;
recurring: string; recurring: string;
@@ -21,36 +31,225 @@ export interface Subscription {
} }
export interface Invoice { export interface Invoice {
stripeInvoiceId: string;
currency: string; currency: string;
amount: number; amount: number;
status: string; status: string;
createdAt: Date; reason: string | null;
lastPaymentError: string | null; lastPaymentError: string | null;
link: string | null; link: string | null;
} }
export interface SubscriptionManager { export const SubscriptionIdentity = z.object({
filterPrices( plan: z.nativeEnum(SubscriptionPlan),
});
export const CheckoutParams = z.object({
plan: z.nativeEnum(SubscriptionPlan),
recurring: z.nativeEnum(SubscriptionRecurring),
variant: z.nativeEnum(SubscriptionVariant).nullable().optional(),
coupon: z.string().nullable().optional(),
quantity: z.number().min(1).nullable().optional(),
successCallbackLink: z.string(),
});
export abstract class SubscriptionManager {
protected readonly scheduleManager = new ScheduleManager(this.stripe);
constructor(
protected readonly stripe: Stripe,
protected readonly db: PrismaClient
) {}
abstract filterPrices(
prices: KnownStripePrice[], prices: KnownStripePrice[],
customer?: UserStripeCustomer customer?: UserStripeCustomer
): Promise<KnownStripePrice[]>; ): KnownStripePrice[] | Promise<KnownStripePrice[]>;
saveSubscription( abstract checkout(
price: KnownStripePrice,
params: z.infer<typeof CheckoutParams>,
args: any
): Promise<Stripe.Checkout.Session>;
abstract saveStripeSubscription(
subscription: KnownStripeSubscription subscription: KnownStripeSubscription
): Promise<Subscription>; ): Promise<Subscription>;
deleteSubscription(subscription: KnownStripeSubscription): Promise<void>; abstract deleteStripeSubscription(
subscription: KnownStripeSubscription
): Promise<void>;
getSubscription( abstract getSubscription(
id: string, identity: z.infer<typeof SubscriptionIdentity>
plan: SubscriptionPlan
): Promise<Subscription | null>; ): Promise<Subscription | null>;
abstract cancelSubscription(
subscription: Subscription
): Promise<Subscription>;
cancelSubscription(subscription: Subscription): Promise<Subscription>; abstract resumeSubscription(
subscription: Subscription
): Promise<Subscription>;
resumeSubscription(subscription: Subscription): Promise<Subscription>; abstract updateSubscriptionRecurring(
updateSubscriptionRecurring(
subscription: Subscription, subscription: Subscription,
recurring: SubscriptionRecurring recurring: SubscriptionRecurring
): Promise<Subscription>; ): Promise<Subscription>;
abstract saveInvoice(knownInvoice: KnownStripeInvoice): Promise<Invoice>;
transformSubscription({
lookupKey,
stripeSubscription: subscription,
}: KnownStripeSubscription): Subscription {
return {
...lookupKey,
stripeScheduleId: subscription.schedule as string | null,
stripeSubscriptionId: subscription.id,
status: subscription.status,
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: !subscription.canceled_at
? new Date(subscription.current_period_end * 1000)
: null,
canceledAt: subscription.canceled_at
? new Date(subscription.canceled_at * 1000)
: null,
};
}
async transformInvoice({
stripeInvoice,
}: KnownStripeInvoice): Promise<Invoice> {
const status = stripeInvoice.status ?? 'void';
let error: string | boolean | null = null;
if (status !== 'paid') {
if (stripeInvoice.last_finalization_error) {
error = stripeInvoice.last_finalization_error.message ?? true;
} else if (
stripeInvoice.attempt_count > 1 &&
stripeInvoice.payment_intent
) {
const paymentIntent =
typeof stripeInvoice.payment_intent === 'string'
? await this.stripe.paymentIntents.retrieve(
stripeInvoice.payment_intent
)
: stripeInvoice.payment_intent;
if (paymentIntent.last_payment_error) {
error = paymentIntent.last_payment_error.message ?? true;
}
}
}
// fallback to generic error message
if (error === true) {
error = 'Payment Error. Please contact support.';
}
return {
stripeInvoiceId: stripeInvoice.id,
status,
link: stripeInvoice.hosted_invoice_url || null,
reason: stripeInvoice.billing_reason,
amount: stripeInvoice.total,
currency: stripeInvoice.currency,
lastPaymentError: error,
};
}
async getOrCreateCustomer(userId: string): Promise<UserStripeCustomer> {
const user = await this.db.user.findUnique({
where: {
id: userId,
},
select: {
email: true,
userStripeCustomer: true,
},
});
if (!user) {
throw new UserNotFound();
}
let customer = user.userStripeCustomer;
if (!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,
});
}
customer = await this.db.userStripeCustomer.create({
data: {
userId,
stripeCustomerId: stripeCustomer.id,
},
});
}
return customer;
}
protected async getPrice(
lookupKey: LookupKey
): Promise<KnownStripePrice | null> {
const prices = await this.stripe.prices.list({
lookup_keys: [encodeLookupKey(lookupKey)],
limit: 1,
});
const price = prices.data[0];
return price
? {
lookupKey,
price,
}
: null;
}
protected async getCouponFromPromotionCode(
userFacingPromotionCode: string,
customer: UserStripeCustomer
) {
const list = await this.stripe.promotionCodes.list({
code: userFacingPromotionCode,
active: true,
limit: 1,
});
const code = list.data[0];
if (!code) {
return null;
}
// the coupons are always bound to products, we need to check it first
// but the logic would be too complicated, and stripe will complain if the code is not applicable when checking out
// It's safe to skip the check here
// code.coupon.applies_to.products.forEach()
// check if the code is bound to a specific customer
return !code.customer ||
(typeof code.customer === 'string'
? code.customer === customer.stripeCustomerId
: code.customer.id === customer.stripeCustomerId)
? code.coupon.id
: null;
}
} }

View File

@@ -1,2 +1,3 @@
export * from './common'; export * from './common';
export * from './user'; export * from './user';
export * from './workspace';

View File

@@ -1,10 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import { PrismaClient, UserStripeCustomer } from '@prisma/client';
PrismaClient, import { omit, pick } from 'lodash-es';
UserStripeCustomer,
UserSubscription,
} from '@prisma/client';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { z } from 'zod';
import { import {
EarlyAccessType, EarlyAccessType,
@@ -14,6 +12,9 @@ import {
Config, Config,
EventEmitter, EventEmitter,
InternalServerError, InternalServerError,
SubscriptionAlreadyExists,
SubscriptionPlanNotFound,
URLHelper,
} from '../../../fundamentals'; } from '../../../fundamentals';
import { import {
CouponType, CouponType,
@@ -26,7 +27,7 @@ import {
SubscriptionStatus, SubscriptionStatus,
SubscriptionVariant, SubscriptionVariant,
} from '../types'; } from '../types';
import { SubscriptionManager } from './common'; import { CheckoutParams, Subscription, SubscriptionManager } from './common';
interface PriceStrategyStatus { interface PriceStrategyStatus {
proEarlyAccess: boolean; proEarlyAccess: boolean;
@@ -36,15 +37,30 @@ interface PriceStrategyStatus {
onetime: boolean; onetime: boolean;
} }
export const UserSubscriptionIdentity = z.object({
plan: z.enum([SubscriptionPlan.Pro, SubscriptionPlan.AI]),
userId: z.string(),
});
export const UserSubscriptionCheckoutArgs = z.object({
user: z.object({
id: z.string(),
email: z.string(),
}),
});
@Injectable() @Injectable()
export class UserSubscriptionManager implements SubscriptionManager { export class UserSubscriptionManager extends SubscriptionManager {
constructor( constructor(
private readonly db: PrismaClient, stripe: Stripe,
db: PrismaClient,
private readonly config: Config, private readonly config: Config,
private readonly stripe: Stripe,
private readonly feature: FeatureManagementService, private readonly feature: FeatureManagementService,
private readonly event: EventEmitter private readonly event: EventEmitter,
) {} private readonly url: URLHelper
) {
super(stripe, db);
}
async filterPrices( async filterPrices(
prices: KnownStripePrice[], prices: KnownStripePrice[],
@@ -71,11 +87,105 @@ export class UserSubscriptionManager implements SubscriptionManager {
return availablePrices; return availablePrices;
} }
async getSubscription(userId: string, plan: SubscriptionPlan) { async checkout(
return this.db.userSubscription.findFirst({ price: KnownStripePrice,
params: z.infer<typeof CheckoutParams>,
{ user }: z.infer<typeof UserSubscriptionCheckoutArgs>
) {
const lookupKey = price.lookupKey;
const subscription = await this.getSubscription({
// @ts-expect-error filtered already
plan: price.lookupKey.plan,
user,
});
if (
subscription &&
// do not allow to re-subscribe unless
!(
/* current subscription is a onetime subscription and so as the one that's checking out */
(
(subscription.variant === SubscriptionVariant.Onetime &&
lookupKey.variant === SubscriptionVariant.Onetime) ||
/* current subscription is normal subscription and is checking-out a lifetime subscription */
(subscription.recurring !== SubscriptionRecurring.Lifetime &&
subscription.variant !== SubscriptionVariant.Onetime &&
lookupKey.recurring === SubscriptionRecurring.Lifetime)
)
)
) {
throw new SubscriptionAlreadyExists({ plan: lookupKey.plan });
}
const customer = await this.getOrCreateCustomer(user.id);
const strategy = await this.strategyStatus(customer);
const available = await this.isPriceAvailable(price, {
...strategy,
onetime: true,
});
if (!available) {
throw new SubscriptionPlanNotFound({
plan: lookupKey.plan,
recurring: lookupKey.recurring,
});
}
const discounts = await (async () => {
const coupon = await this.getBuildInCoupon(customer, price);
if (coupon) {
return { discounts: [{ coupon }] };
} else if (params.coupon) {
const couponId = await this.getCouponFromPromotionCode(
params.coupon,
customer
);
if (couponId) {
return { discounts: [{ coupon: couponId }] };
}
}
return { allow_promotion_codes: true };
})();
// mode: 'subscription' or 'payment' for lifetime and onetime payment
const mode =
lookupKey.recurring === SubscriptionRecurring.Lifetime ||
lookupKey.variant === SubscriptionVariant.Onetime
? {
mode: 'payment' as const,
invoice_creation: {
enabled: true,
},
}
: {
mode: 'subscription' as const,
};
return this.stripe.checkout.sessions.create({
line_items: [
{
price: price.price.id,
quantity: 1,
},
],
tax_id_collection: {
enabled: true,
},
...discounts,
...mode,
success_url: this.url.link(params.successCallbackLink, {
session_id: '{CHECKOUT_SESSION_ID}',
}),
customer: customer.stripeCustomerId,
});
}
async getSubscription(args: z.infer<typeof UserSubscriptionIdentity>) {
return this.db.subscription.findFirst({
where: { where: {
userId, targetId: args.userId,
plan, plan: args.plan,
status: { status: {
in: [SubscriptionStatus.Active, SubscriptionStatus.Trialing], in: [SubscriptionStatus.Active, SubscriptionStatus.Trialing],
}, },
@@ -83,11 +193,8 @@ export class UserSubscriptionManager implements SubscriptionManager {
}); });
} }
async saveSubscription({ async saveStripeSubscription(subscription: KnownStripeSubscription) {
userId, const { userId, lookupKey, stripeSubscription } = subscription;
lookupKey,
stripeSubscription: subscription,
}: KnownStripeSubscription) {
// update features first, features modify are idempotent // update features first, features modify are idempotent
// so there is no need to skip if a subscription already exists. // so there is no need to skip if a subscription already exists.
// TODO(@forehalo): // TODO(@forehalo):
@@ -99,43 +206,85 @@ export class UserSubscriptionManager implements SubscriptionManager {
recurring: lookupKey.recurring, recurring: lookupKey.recurring,
}); });
const commonData = { const subscriptionData = this.transformSubscription(subscription);
status: subscription.status,
stripeScheduleId: subscription.schedule as string | null,
nextBillAt: !subscription.canceled_at
? new Date(subscription.current_period_end * 1000)
: null,
canceledAt: subscription.canceled_at
? new Date(subscription.canceled_at * 1000)
: null,
};
return await this.db.userSubscription.upsert({ // @deprecated backward compatibility
await this.db.deprecatedUserSubscription.upsert({
where: { where: {
stripeSubscriptionId: subscription.id, stripeSubscriptionId: stripeSubscription.id,
}, },
update: commonData, update: pick(subscriptionData, [
'status',
'stripeScheduleId',
'nextBillAt',
'canceledAt',
]),
create: { create: {
userId, userId,
...lookupKey, ...subscriptionData,
stripeSubscriptionId: subscription.id, },
start: new Date(subscription.current_period_start * 1000), });
end: new Date(subscription.current_period_end * 1000),
trialStart: subscription.trial_start return this.db.subscription.upsert({
? new Date(subscription.trial_start * 1000) where: {
: null, stripeSubscriptionId: stripeSubscription.id,
trialEnd: subscription.trial_end },
? new Date(subscription.trial_end * 1000) update: pick(subscriptionData, [
: null, 'status',
...commonData, 'stripeScheduleId',
'nextBillAt',
'canceledAt',
]),
create: {
targetId: userId,
...subscriptionData,
}, },
}); });
} }
async cancelSubscription(subscription: UserSubscription) { async deleteStripeSubscription({
return this.db.userSubscription.update({ userId,
lookupKey,
stripeSubscription,
}: KnownStripeSubscription) {
const deleted = await this.db.subscription.deleteMany({
where: { where: {
id: subscription.id, stripeSubscriptionId: stripeSubscription.id,
},
});
// @deprecated backward compatibility
await this.db.deprecatedUserSubscription.deleteMany({
where: {
stripeSubscriptionId: stripeSubscription.id,
},
});
if (deleted.count > 0) {
this.event.emit('user.subscription.canceled', {
userId,
plan: lookupKey.plan,
recurring: lookupKey.recurring,
});
}
}
async cancelSubscription(subscription: Subscription) {
// @deprecated backward compatibility
await this.db.deprecatedUserSubscription.updateMany({
where: {
stripeSubscriptionId: subscription.stripeSubscriptionId,
},
data: {
canceledAt: new Date(),
nextBillAt: null,
},
});
return this.db.subscription.update({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
}, },
data: { data: {
canceledAt: new Date(), canceledAt: new Date(),
@@ -144,9 +293,23 @@ export class UserSubscriptionManager implements SubscriptionManager {
}); });
} }
async resumeSubscription(subscription: UserSubscription) { async resumeSubscription(subscription: Subscription) {
return this.db.userSubscription.update({ // @deprecated backward compatibility
where: { id: subscription.id }, await this.db.deprecatedUserSubscription.updateMany({
where: {
stripeSubscriptionId: subscription.stripeSubscriptionId,
},
data: {
canceledAt: null,
nextBillAt: subscription.end,
},
});
return this.db.subscription.update({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
},
data: { data: {
canceledAt: null, canceledAt: null,
nextBillAt: subscription.end, nextBillAt: subscription.end,
@@ -155,34 +318,30 @@ export class UserSubscriptionManager implements SubscriptionManager {
} }
async updateSubscriptionRecurring( async updateSubscriptionRecurring(
subscription: UserSubscription, subscription: Subscription,
recurring: SubscriptionRecurring recurring: SubscriptionRecurring
) { ) {
return this.db.userSubscription.update({ // @deprecated backward compatibility
where: { id: subscription.id }, await this.db.deprecatedUserSubscription.updateMany({
where: {
stripeSubscriptionId: subscription.stripeSubscriptionId,
},
data: { recurring },
});
return this.db.subscription.update({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
},
data: { recurring }, data: { recurring },
}); });
} }
async deleteSubscription({ private async getBuildInCoupon(
userId, customer: UserStripeCustomer,
lookupKey, price: KnownStripePrice
stripeSubscription, ) {
}: KnownStripeSubscription) {
await this.db.userSubscription.delete({
where: {
stripeSubscriptionId: stripeSubscription.id,
},
});
this.event.emit('user.subscription.canceled', {
userId,
plan: lookupKey.plan,
recurring: lookupKey.recurring,
});
}
async validatePrice(price: KnownStripePrice, customer: UserStripeCustomer) {
const strategyStatus = await this.strategyStatus(customer); const strategyStatus = await this.strategyStatus(customer);
// onetime price is allowed for checkout // onetime price is allowed for checkout
@@ -192,7 +351,7 @@ export class UserSubscriptionManager implements SubscriptionManager {
return null; return null;
} }
let coupon: CouponType | null = null; let coupon: CouponType | undefined;
if (price.lookupKey.variant === SubscriptionVariant.EA) { if (price.lookupKey.variant === SubscriptionVariant.EA) {
if (price.lookupKey.plan === SubscriptionPlan.Pro) { if (price.lookupKey.plan === SubscriptionPlan.Pro) {
@@ -207,69 +366,40 @@ export class UserSubscriptionManager implements SubscriptionManager {
} }
} }
return { return coupon;
price,
coupon,
};
} }
async saveInvoice(knownInvoice: KnownStripeInvoice) { async saveInvoice(knownInvoice: KnownStripeInvoice) {
const { userId, lookupKey, stripeInvoice } = knownInvoice; const { userId, lookupKey, stripeInvoice } = knownInvoice;
const status = stripeInvoice.status ?? 'void'; const invoiceData = await this.transformInvoice(knownInvoice);
let error: string | boolean | null = null;
if (status !== 'paid') { // @deprecated backward compatibility
if (stripeInvoice.last_finalization_error) { await this.db.deprecatedUserInvoice.upsert({
error = stripeInvoice.last_finalization_error.message ?? true;
} else if (
stripeInvoice.attempt_count > 1 &&
stripeInvoice.payment_intent
) {
const paymentIntent =
typeof stripeInvoice.payment_intent === 'string'
? await this.stripe.paymentIntents.retrieve(
stripeInvoice.payment_intent
)
: stripeInvoice.payment_intent;
if (paymentIntent.last_payment_error) {
error = paymentIntent.last_payment_error.message ?? true;
}
}
}
// fallback to generic error message
if (error === true) {
error = 'Payment Error. Please contact support.';
}
const invoice = this.db.userInvoice.upsert({
where: { where: {
stripeInvoiceId: stripeInvoice.id, stripeInvoiceId: stripeInvoice.id,
}, },
update: { update: omit(invoiceData, 'stripeInvoiceId'),
status,
link: stripeInvoice.hosted_invoice_url,
amount: stripeInvoice.total,
currency: stripeInvoice.currency,
lastPaymentError: error,
},
create: { create: {
userId, userId,
...invoiceData,
},
});
const invoice = this.db.invoice.upsert({
where: {
stripeInvoiceId: stripeInvoice.id, stripeInvoiceId: stripeInvoice.id,
status, },
link: stripeInvoice.hosted_invoice_url, update: omit(invoiceData, 'stripeInvoiceId'),
reason: stripeInvoice.billing_reason, create: {
amount: stripeInvoice.total, targetId: userId,
currency: stripeInvoice.currency, ...invoiceData,
lastPaymentError: error,
}, },
}); });
// onetime and lifetime subscription is a special "subscription" that doesn't get involved with stripe subscription system // onetime and lifetime subscription is a special "subscription" that doesn't get involved with stripe subscription system
// we track the deals by invoice only. // we track the deals by invoice only.
if (status === 'paid') { if (stripeInvoice.status === 'paid') {
if (lookupKey.recurring === SubscriptionRecurring.Lifetime) { if (lookupKey.recurring === SubscriptionRecurring.Lifetime) {
await this.saveLifetimeSubscription(knownInvoice); await this.saveLifetimeSubscription(knownInvoice);
} else if (lookupKey.variant === SubscriptionVariant.Onetime) { } else if (lookupKey.variant === SubscriptionVariant.Onetime) {
@@ -282,45 +412,49 @@ export class UserSubscriptionManager implements SubscriptionManager {
async saveLifetimeSubscription( async saveLifetimeSubscription(
knownInvoice: KnownStripeInvoice knownInvoice: KnownStripeInvoice
): Promise<UserSubscription> { ): Promise<Subscription> {
// cancel previous non-lifetime subscription // cancel previous non-lifetime subscription
const prevSubscription = await this.db.userSubscription.findUnique({ const prevSubscription = await this.db.subscription.findUnique({
where: { where: {
userId_plan: { targetId_plan: {
userId: knownInvoice.userId, targetId: knownInvoice.userId,
plan: SubscriptionPlan.Pro, plan: SubscriptionPlan.Pro,
}, },
}, },
}); });
let subscription: UserSubscription; let subscription: Subscription;
if (prevSubscription && prevSubscription.stripeSubscriptionId) { if (prevSubscription) {
subscription = await this.db.userSubscription.update({ if (prevSubscription.stripeSubscriptionId) {
where: { subscription = await this.db.subscription.update({
id: prevSubscription.id, where: {
}, id: prevSubscription.id,
data: { },
stripeScheduleId: null, data: {
stripeSubscriptionId: null, stripeScheduleId: null,
plan: knownInvoice.lookupKey.plan, stripeSubscriptionId: null,
recurring: SubscriptionRecurring.Lifetime, plan: knownInvoice.lookupKey.plan,
start: new Date(), recurring: SubscriptionRecurring.Lifetime,
end: null, start: new Date(),
status: SubscriptionStatus.Active, end: null,
nextBillAt: null, status: SubscriptionStatus.Active,
}, nextBillAt: null,
}); },
});
await this.stripe.subscriptions.cancel( await this.stripe.subscriptions.cancel(
prevSubscription.stripeSubscriptionId, prevSubscription.stripeSubscriptionId,
{ {
prorate: true, prorate: true,
} }
); );
} else {
subscription = prevSubscription;
}
} else { } else {
subscription = await this.db.userSubscription.create({ subscription = await this.db.subscription.create({
data: { data: {
userId: knownInvoice.userId, targetId: knownInvoice.userId,
stripeSubscriptionId: null, stripeSubscriptionId: null,
plan: knownInvoice.lookupKey.plan, plan: knownInvoice.lookupKey.plan,
recurring: SubscriptionRecurring.Lifetime, recurring: SubscriptionRecurring.Lifetime,
@@ -343,12 +477,13 @@ export class UserSubscriptionManager implements SubscriptionManager {
async saveOnetimePaymentSubscription( async saveOnetimePaymentSubscription(
knownInvoice: KnownStripeInvoice knownInvoice: KnownStripeInvoice
): Promise<UserSubscription> { ): Promise<Subscription> {
// TODO(@forehalo): identify whether the invoice has already been redeemed.
const { userId, lookupKey } = knownInvoice; const { userId, lookupKey } = knownInvoice;
const existingSubscription = await this.db.userSubscription.findUnique({ const existingSubscription = await this.db.subscription.findUnique({
where: { where: {
userId_plan: { targetId_plan: {
userId, targetId: userId,
plan: lookupKey.plan, plan: lookupKey.plan,
}, },
}, },
@@ -362,7 +497,7 @@ export class UserSubscriptionManager implements SubscriptionManager {
60 * 60 *
1000; 1000;
let subscription: UserSubscription; let subscription: Subscription;
// extends the subscription time if exists // extends the subscription time if exists
if (existingSubscription) { if (existingSubscription) {
@@ -385,16 +520,16 @@ export class UserSubscriptionManager implements SubscriptionManager {
), ),
}; };
subscription = await this.db.userSubscription.update({ subscription = await this.db.subscription.update({
where: { where: {
id: existingSubscription.id, id: existingSubscription.id,
}, },
data: period, data: period,
}); });
} else { } else {
subscription = await this.db.userSubscription.create({ subscription = await this.db.subscription.create({
data: { data: {
userId, targetId: userId,
stripeSubscriptionId: null, stripeSubscriptionId: null,
...lookupKey, ...lookupKey,
start: new Date(), start: new Date(),

View File

@@ -0,0 +1,305 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient, UserStripeCustomer } from '@prisma/client';
import { omit, pick } from 'lodash-es';
import Stripe from 'stripe';
import { z } from 'zod';
import {
EventEmitter,
type EventPayload,
OnEvent,
SubscriptionAlreadyExists,
URLHelper,
} from '../../../fundamentals';
import {
KnownStripeInvoice,
KnownStripePrice,
KnownStripeSubscription,
retriveLookupKeyFromStripeSubscription,
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '../types';
import {
CheckoutParams,
Invoice,
Subscription,
SubscriptionManager,
} from './common';
export const WorkspaceSubscriptionIdentity = z.object({
plan: z.literal(SubscriptionPlan.Team),
workspaceId: z.string(),
});
export const WorkspaceSubscriptionCheckoutArgs = z.object({
plan: z.literal(SubscriptionPlan.Team),
workspaceId: z.string(),
user: z.object({
id: z.string(),
email: z.string(),
}),
});
@Injectable()
export class WorkspaceSubscriptionManager extends SubscriptionManager {
constructor(
stripe: Stripe,
db: PrismaClient,
private readonly url: URLHelper,
private readonly event: EventEmitter
) {
super(stripe, db);
}
filterPrices(
prices: KnownStripePrice[],
_customer?: UserStripeCustomer
): KnownStripePrice[] {
return prices.filter(
price => price.lookupKey.plan === SubscriptionPlan.Team
);
}
async checkout(
{ price }: KnownStripePrice,
params: z.infer<typeof CheckoutParams>,
args: z.infer<typeof WorkspaceSubscriptionCheckoutArgs>
) {
const subscription = await this.getSubscription({
plan: SubscriptionPlan.Team,
workspaceId: args.workspaceId,
});
if (subscription) {
throw new SubscriptionAlreadyExists({ plan: SubscriptionPlan.Team });
}
const customer = await this.getOrCreateCustomer(args.user.id);
const discounts = await (async () => {
if (params.coupon) {
const couponId = await this.getCouponFromPromotionCode(
params.coupon,
customer
);
if (couponId) {
return { discounts: [{ coupon: couponId }] };
}
}
return { allow_promotion_codes: true };
})();
const count = await this.db.workspaceUserPermission.count({
where: {
workspaceId: args.workspaceId,
// @TODO(darksky): replace with [status: WorkspaceUserPermissionStatus.Accepted]
accepted: true,
},
});
return this.stripe.checkout.sessions.create({
line_items: [
{
price: price.id,
quantity: count,
},
],
tax_id_collection: {
enabled: true,
},
...discounts,
mode: 'subscription',
success_url: this.url.link(params.successCallbackLink),
customer: customer.stripeCustomerId,
subscription_data: {
metadata: {
workspaceId: args.workspaceId,
},
},
});
}
async saveStripeSubscription(subscription: KnownStripeSubscription) {
const { lookupKey, quantity, stripeSubscription } = subscription;
const workspaceId = stripeSubscription.metadata.workspaceId;
if (!workspaceId) {
throw new Error(
'Workspace ID is required in workspace subscription metadata'
);
}
this.event.emit('workspace.subscription.activated', {
workspaceId,
plan: lookupKey.plan,
recurring: lookupKey.recurring,
quantity,
});
const subscriptionData = this.transformSubscription(subscription);
return this.db.subscription.upsert({
where: {
stripeSubscriptionId: stripeSubscription.id,
},
update: {
quantity,
...pick(subscriptionData, [
'status',
'stripeScheduleId',
'nextBillAt',
'canceledAt',
]),
},
create: {
targetId: workspaceId,
quantity,
...subscriptionData,
},
});
}
async deleteStripeSubscription({
lookupKey,
stripeSubscription,
}: KnownStripeSubscription) {
const workspaceId = stripeSubscription.metadata.workspaceId;
if (!workspaceId) {
throw new Error(
'Workspace ID is required in workspace subscription metadata'
);
}
const deleted = await this.db.subscription.deleteMany({
where: { stripeSubscriptionId: stripeSubscription.id },
});
if (deleted.count > 0) {
this.event.emit('workspace.subscription.canceled', {
workspaceId,
plan: lookupKey.plan,
recurring: lookupKey.recurring,
});
}
}
getSubscription(identity: z.infer<typeof WorkspaceSubscriptionIdentity>) {
return this.db.subscription.findFirst({
where: {
targetId: identity.workspaceId,
status: {
in: [SubscriptionStatus.Active, SubscriptionStatus.Trialing],
},
},
});
}
async cancelSubscription(subscription: Subscription) {
return await this.db.subscription.update({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
},
data: {
canceledAt: new Date(),
nextBillAt: null,
},
});
}
resumeSubscription(subscription: Subscription): Promise<Subscription> {
return this.db.subscription.update({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
},
data: {
canceledAt: null,
nextBillAt: subscription.end,
},
});
}
updateSubscriptionRecurring(
subscription: Subscription,
recurring: SubscriptionRecurring
): Promise<Subscription> {
return this.db.subscription.update({
where: {
// @ts-expect-error checked outside
stripeSubscriptionId: subscription.stripeSubscriptionId,
},
data: { recurring },
});
}
async saveInvoice(knownInvoice: KnownStripeInvoice): Promise<Invoice> {
const { metadata, stripeInvoice } = knownInvoice;
const workspaceId = metadata.workspaceId;
if (!workspaceId) {
throw new Error('Workspace ID is required in workspace invoice metadata');
}
const invoiceData = await this.transformInvoice(knownInvoice);
return this.db.invoice.upsert({
where: {
stripeInvoiceId: stripeInvoice.id,
},
update: omit(invoiceData, 'stripeInvoiceId'),
create: {
targetId: workspaceId,
...invoiceData,
},
});
}
@OnEvent('workspace.members.updated')
async onMembersUpdated({
workspaceId,
count,
}: EventPayload<'workspace.members.updated'>) {
const subscription = await this.getSubscription({
plan: SubscriptionPlan.Team,
workspaceId,
});
if (!subscription || !subscription.stripeSubscriptionId) {
return;
}
const stripeSubscription = await this.stripe.subscriptions.retrieve(
subscription.stripeSubscriptionId
);
const lookupKey =
retriveLookupKeyFromStripeSubscription(stripeSubscription);
await this.stripe.subscriptions.update(stripeSubscription.id, {
items: [
{
id: stripeSubscription.items.data[0].id,
quantity: count,
},
],
payment_behavior: 'pending_if_incomplete',
proration_behavior:
lookupKey?.recurring === SubscriptionRecurring.Yearly
? 'always_invoice'
: 'none',
});
if (subscription.stripeScheduleId) {
const schedule = await this.scheduleManager.fromSchedule(
subscription.stripeScheduleId
);
await schedule.updateQuantity(count);
}
}
}

View File

@@ -12,15 +12,23 @@ import {
ResolveField, ResolveField,
Resolver, Resolver,
} from '@nestjs/graphql'; } from '@nestjs/graphql';
import type { User, UserSubscription } from '@prisma/client'; import type { User } from '@prisma/client';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import { GraphQLJSONObject } from 'graphql-scalars';
import { groupBy } from 'lodash-es'; import { groupBy } from 'lodash-es';
import { z } from 'zod';
import { CurrentUser, Public } from '../../core/auth'; import { CurrentUser, Public } from '../../core/auth';
import { Permission, PermissionService } from '../../core/permission';
import { UserType } from '../../core/user'; import { UserType } from '../../core/user';
import { AccessDenied, FailedToCheckout, URLHelper } from '../../fundamentals'; import { WorkspaceType } from '../../core/workspaces';
import { Invoice, Subscription } from './manager'; import {
import { SubscriptionService } from './service'; AccessDenied,
FailedToCheckout,
WorkspaceIdRequiredToUpdateTeamSubscription,
} from '../../fundamentals';
import { Invoice, Subscription, WorkspaceSubscriptionManager } from './manager';
import { CheckoutParams, SubscriptionService } from './service';
import { import {
InvoiceStatus, InvoiceStatus,
SubscriptionPlan, SubscriptionPlan,
@@ -57,7 +65,7 @@ class SubscriptionPrice {
} }
@ObjectType() @ObjectType()
export class SubscriptionType implements Subscription { export class SubscriptionType implements Partial<Subscription> {
@Field(() => SubscriptionPlan, { @Field(() => SubscriptionPlan, {
description: description:
"The 'Free' plan just exists to be a placeholder and for the type convenience of frontend.\nThere won't actually be a subscription with plan 'Free'", "The 'Free' plan just exists to be a placeholder and for the type convenience of frontend.\nThere won't actually be a subscription with plan 'Free'",
@@ -107,7 +115,7 @@ export class SubscriptionType implements Subscription {
} }
@ObjectType() @ObjectType()
export class InvoiceType implements Invoice { export class InvoiceType implements Partial<Invoice> {
@Field() @Field()
currency!: string; currency!: string;
@@ -138,7 +146,7 @@ export class InvoiceType implements Invoice {
nullable: true, nullable: true,
deprecationReason: 'removed', deprecationReason: 'removed',
}) })
stripeInvoiceId!: string | null; stripeInvoiceId?: string;
@Field(() => SubscriptionPlan, { @Field(() => SubscriptionPlan, {
nullable: true, nullable: true,
@@ -154,7 +162,7 @@ export class InvoiceType implements Invoice {
} }
@InputType() @InputType()
class CreateCheckoutSessionInput { class CreateCheckoutSessionInput implements z.infer<typeof CheckoutParams> {
@Field(() => SubscriptionRecurring, { @Field(() => SubscriptionRecurring, {
nullable: true, nullable: true,
defaultValue: SubscriptionRecurring.Yearly, defaultValue: SubscriptionRecurring.Yearly,
@@ -170,7 +178,7 @@ class CreateCheckoutSessionInput {
@Field(() => SubscriptionVariant, { @Field(() => SubscriptionVariant, {
nullable: true, nullable: true,
}) })
variant?: SubscriptionVariant; variant!: SubscriptionVariant | null;
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
coupon!: string | null; coupon!: string | null;
@@ -180,17 +188,17 @@ class CreateCheckoutSessionInput {
@Field(() => String, { @Field(() => String, {
nullable: true, nullable: true,
deprecationReason: 'use header `Idempotency-Key`', deprecationReason: 'not required anymore',
}) })
idempotencyKey?: string; idempotencyKey?: string;
@Field(() => GraphQLJSONObject, { nullable: true })
args!: { workspaceId?: string };
} }
@Resolver(() => SubscriptionType) @Resolver(() => SubscriptionType)
export class SubscriptionResolver { export class SubscriptionResolver {
constructor( constructor(private readonly service: SubscriptionService) {}
private readonly service: SubscriptionService,
private readonly url: URLHelper
) {}
@Public() @Public()
@Query(() => [SubscriptionPrice]) @Query(() => [SubscriptionPrice])
@@ -232,7 +240,11 @@ export class SubscriptionResolver {
} }
// extend it when new plans are added // extend it when new plans are added
const fixedPlans = [SubscriptionPlan.Pro, SubscriptionPlan.AI]; const fixedPlans = [
SubscriptionPlan.Pro,
SubscriptionPlan.AI,
SubscriptionPlan.Team,
];
return fixedPlans.reduce((prices, plan) => { return fixedPlans.reduce((prices, plan) => {
const price = findPrice(plan); const price = findPrice(plan);
@@ -255,26 +267,19 @@ export class SubscriptionResolver {
async createCheckoutSession( async createCheckoutSession(
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Args({ name: 'input', type: () => CreateCheckoutSessionInput }) @Args({ name: 'input', type: () => CreateCheckoutSessionInput })
input: CreateCheckoutSessionInput, input: CreateCheckoutSessionInput
@Headers('idempotency-key') idempotencyKey?: string
) { ) {
const session = await this.service.checkout({ const session = await this.service.checkout(input, {
plan: input.plan as any,
user, user,
lookupKey: { workspaceId: input.args?.workspaceId,
plan: input.plan,
recurring: input.recurring,
variant: input.variant,
},
promotionCode: input.coupon,
redirectUrl: this.url.link(input.successCallbackLink),
idempotencyKey,
}); });
if (!session.url) { if (!session.url) {
throw new FailedToCheckout(); throw new FailedToCheckout();
} }
return session.url; return session;
} }
@Mutation(() => String, { @Mutation(() => String, {
@@ -294,6 +299,8 @@ export class SubscriptionResolver {
defaultValue: SubscriptionPlan.Pro, defaultValue: SubscriptionPlan.Pro,
}) })
plan: SubscriptionPlan, plan: SubscriptionPlan,
@Args({ name: 'workspaceId', type: () => String, nullable: true })
workspaceId: string | null,
@Headers('idempotency-key') idempotencyKey?: string, @Headers('idempotency-key') idempotencyKey?: string,
@Args('idempotencyKey', { @Args('idempotencyKey', {
type: () => String, type: () => String,
@@ -302,7 +309,25 @@ export class SubscriptionResolver {
}) })
_?: string _?: string
) { ) {
return this.service.cancelSubscription(user.id, plan, idempotencyKey); if (plan === SubscriptionPlan.Team) {
if (!workspaceId) {
throw new WorkspaceIdRequiredToUpdateTeamSubscription();
}
return this.service.cancelSubscription(
{ workspaceId, plan },
idempotencyKey
);
}
return this.service.cancelSubscription(
{
targetId: user.id,
// @ts-expect-error exam inside
plan,
},
idempotencyKey
);
} }
@Mutation(() => SubscriptionType) @Mutation(() => SubscriptionType)
@@ -315,6 +340,8 @@ export class SubscriptionResolver {
defaultValue: SubscriptionPlan.Pro, defaultValue: SubscriptionPlan.Pro,
}) })
plan: SubscriptionPlan, plan: SubscriptionPlan,
@Args({ name: 'workspaceId', type: () => String, nullable: true })
workspaceId: string | null,
@Headers('idempotency-key') idempotencyKey?: string, @Headers('idempotency-key') idempotencyKey?: string,
@Args('idempotencyKey', { @Args('idempotencyKey', {
type: () => String, type: () => String,
@@ -323,14 +350,30 @@ export class SubscriptionResolver {
}) })
_?: string _?: string
) { ) {
return this.service.resumeSubscription(user.id, plan, idempotencyKey); if (plan === SubscriptionPlan.Team) {
if (!workspaceId) {
throw new WorkspaceIdRequiredToUpdateTeamSubscription();
}
return this.service.resumeSubscription(
{ workspaceId, plan },
idempotencyKey
);
}
return this.service.resumeSubscription(
{
targetId: user.id,
// @ts-expect-error exam inside
plan,
},
idempotencyKey
);
} }
@Mutation(() => SubscriptionType) @Mutation(() => SubscriptionType)
async updateSubscriptionRecurring( async updateSubscriptionRecurring(
@CurrentUser() user: CurrentUser, @CurrentUser() user: CurrentUser,
@Args({ name: 'recurring', type: () => SubscriptionRecurring })
recurring: SubscriptionRecurring,
@Args({ @Args({
name: 'plan', name: 'plan',
type: () => SubscriptionPlan, type: () => SubscriptionPlan,
@@ -338,6 +381,10 @@ export class SubscriptionResolver {
defaultValue: SubscriptionPlan.Pro, defaultValue: SubscriptionPlan.Pro,
}) })
plan: SubscriptionPlan, plan: SubscriptionPlan,
@Args({ name: 'workspaceId', type: () => String, nullable: true })
workspaceId: string | null,
@Args({ name: 'recurring', type: () => SubscriptionRecurring })
recurring: SubscriptionRecurring,
@Headers('idempotency-key') idempotencyKey?: string, @Headers('idempotency-key') idempotencyKey?: string,
@Args('idempotencyKey', { @Args('idempotencyKey', {
type: () => String, type: () => String,
@@ -346,9 +393,24 @@ export class SubscriptionResolver {
}) })
_?: string _?: string
) { ) {
if (plan === SubscriptionPlan.Team) {
if (!workspaceId) {
throw new WorkspaceIdRequiredToUpdateTeamSubscription();
}
return this.service.updateSubscriptionRecurring(
{ workspaceId, plan },
recurring,
idempotencyKey
);
}
return this.service.updateSubscriptionRecurring( return this.service.updateSubscriptionRecurring(
user.id, {
plan, userId: user.id,
// @ts-expect-error exam inside
plan,
},
recurring, recurring,
idempotencyKey idempotencyKey
); );
@@ -363,14 +425,14 @@ export class UserSubscriptionResolver {
async subscriptions( async subscriptions(
@CurrentUser() me: User, @CurrentUser() me: User,
@Parent() user: User @Parent() user: User
): Promise<UserSubscription[]> { ): Promise<Subscription[]> {
if (me.id !== user.id) { if (me.id !== user.id) {
throw new AccessDenied(); throw new AccessDenied();
} }
const subscriptions = await this.db.userSubscription.findMany({ const subscriptions = await this.db.subscription.findMany({
where: { where: {
userId: user.id, targetId: user.id,
status: SubscriptionStatus.Active, status: SubscriptionStatus.Active,
}, },
}); });
@@ -389,6 +451,16 @@ export class UserSubscriptionResolver {
return subscriptions; return subscriptions;
} }
@ResolveField(() => Int, {
name: 'invoiceCount',
description: 'Get user invoice count',
})
async invoiceCount(@CurrentUser() user: CurrentUser) {
return this.db.invoice.count({
where: { targetId: user.id },
});
}
@ResolveField(() => [InvoiceType]) @ResolveField(() => [InvoiceType])
async invoices( async invoices(
@CurrentUser() me: User, @CurrentUser() me: User,
@@ -401,14 +473,72 @@ export class UserSubscriptionResolver {
throw new AccessDenied(); throw new AccessDenied();
} }
return this.db.userInvoice.findMany({ return this.db.invoice.findMany({
where: { where: {
userId: user.id, targetId: user.id,
}, },
take, take,
skip, skip,
orderBy: { orderBy: {
id: 'desc', createdAt: 'desc',
},
});
}
}
@Resolver(() => WorkspaceType)
export class WorkspaceSubscriptionResolver {
constructor(
private readonly service: WorkspaceSubscriptionManager,
private readonly db: PrismaClient,
private readonly permission: PermissionService
) {}
@ResolveField(() => SubscriptionType, {
nullable: true,
description: 'The team subscription of the workspace, if exists.',
})
async subscription(@Parent() workspace: WorkspaceType) {
return this.service.getSubscription({
plan: SubscriptionPlan.Team,
workspaceId: workspace.id,
});
}
@ResolveField(() => Int, {
name: 'invoiceCount',
description: 'Get user invoice count',
})
async invoiceCount(
@CurrentUser() me: CurrentUser,
@Parent() workspace: WorkspaceType
) {
await this.permission.checkWorkspace(workspace.id, me.id, Permission.Owner);
return this.db.invoice.count({
where: {
targetId: workspace.id,
},
});
}
@ResolveField(() => [InvoiceType])
async invoices(
@CurrentUser() me: CurrentUser,
@Parent() workspace: WorkspaceType,
@Args('take', { type: () => Int, nullable: true, defaultValue: 8 })
take: number,
@Args('skip', { type: () => Int, nullable: true }) skip?: number
) {
await this.permission.checkWorkspace(workspace.id, me.id, Permission.Owner);
return this.db.invoice.findMany({
where: {
targetId: workspace.id,
},
take,
skip,
orderBy: {
createdAt: 'desc',
}, },
}); });
} }

View File

@@ -101,7 +101,7 @@ export class ScheduleManager {
items: [ items: [
{ {
price: this.currentPhase.items[0].price as string, price: this.currentPhase.items[0].price as string,
quantity: 1, quantity: this.currentPhase.items[0].quantity,
}, },
], ],
coupon: (this.currentPhase.coupon as string | null) ?? undefined, coupon: (this.currentPhase.coupon as string | null) ?? undefined,
@@ -143,10 +143,9 @@ export class ScheduleManager {
items: [ items: [
{ {
price: this.currentPhase.items[0].price as string, price: this.currentPhase.items[0].price as string,
quantity: 1, quantity: this.currentPhase.items[0].quantity,
}, },
], ],
coupon: (this.currentPhase.coupon as string | null) ?? undefined,
start_date: this.currentPhase.start_date, start_date: this.currentPhase.start_date,
end_date: this.currentPhase.end_date, end_date: this.currentPhase.end_date,
metadata: { metadata: {
@@ -161,7 +160,7 @@ export class ScheduleManager {
items: [ items: [
{ {
price: this.currentPhase.metadata.next_price, price: this.currentPhase.metadata.next_price,
quantity: 1, quantity: this.currentPhase.items[0].quantity,
}, },
], ],
coupon: this.currentPhase.metadata.next_coupon || undefined, coupon: this.currentPhase.metadata.next_coupon || undefined,
@@ -212,6 +211,7 @@ export class ScheduleManager {
items: [ items: [
{ {
price: this.currentPhase.items[0].price as string, price: this.currentPhase.items[0].price as string,
quantity: this.currentPhase.items[0].quantity,
}, },
], ],
start_date: this.currentPhase.start_date, start_date: this.currentPhase.start_date,
@@ -221,6 +221,7 @@ export class ScheduleManager {
items: [ items: [
{ {
price: price, price: price,
quantity: this.currentPhase.items[0].quantity,
}, },
], ],
}, },
@@ -230,4 +231,31 @@ export class ScheduleManager {
); );
} }
} }
async updateQuantity(quantity: number, idempotencyKey?: string) {
if (!this._schedule) {
throw new Error('No schedule');
}
if (!this.isActive || !this.currentPhase) {
throw new Error('Unexpected subscription schedule status');
}
await this.stripe.subscriptionSchedules.update(
this._schedule.id,
{
phases: this._schedule.phases.map(phase => ({
items: [
{
price: phase.items[0].price as string,
quantity,
},
],
start_date: phase.start_date,
end_date: phase.end_date,
})),
},
{ idempotencyKey }
);
}
} }

View File

@@ -1,12 +1,8 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import type { import type { User, UserStripeCustomer } from '@prisma/client';
User,
UserInvoice,
UserStripeCustomer,
UserSubscription,
} from '@prisma/client';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { z } from 'zod';
import { CurrentUser } from '../../core/auth'; import { CurrentUser } from '../../core/auth';
import { FeatureManagementService } from '../../core/features'; import { FeatureManagementService } from '../../core/features';
@@ -17,30 +13,56 @@ import {
Config, Config,
CustomerPortalCreateFailed, CustomerPortalCreateFailed,
InternalServerError, InternalServerError,
InvalidCheckoutParameters,
InvalidSubscriptionParameters,
OnEvent, OnEvent,
SameSubscriptionRecurring, SameSubscriptionRecurring,
SubscriptionAlreadyExists,
SubscriptionExpired, SubscriptionExpired,
SubscriptionHasBeenCanceled, SubscriptionHasBeenCanceled,
SubscriptionHasNotBeenCanceled,
SubscriptionNotExists, SubscriptionNotExists,
SubscriptionPlanNotFound, SubscriptionPlanNotFound,
UnsupportedSubscriptionPlan,
UserNotFound, UserNotFound,
} from '../../fundamentals'; } from '../../fundamentals';
import { UserSubscriptionManager } from './manager'; import {
CheckoutParams,
Invoice,
Subscription,
SubscriptionManager,
UserSubscriptionCheckoutArgs,
UserSubscriptionIdentity,
UserSubscriptionManager,
WorkspaceSubscriptionCheckoutArgs,
WorkspaceSubscriptionIdentity,
WorkspaceSubscriptionManager,
} from './manager';
import { ScheduleManager } from './schedule'; import { ScheduleManager } from './schedule';
import { import {
encodeLookupKey, encodeLookupKey,
KnownStripeInvoice, KnownStripeInvoice,
KnownStripePrice, KnownStripePrice,
KnownStripeSubscription,
LookupKey, LookupKey,
retriveLookupKeyFromStripePrice, retriveLookupKeyFromStripePrice,
retriveLookupKeyFromStripeSubscription, retriveLookupKeyFromStripeSubscription,
SubscriptionPlan, SubscriptionPlan,
SubscriptionRecurring, SubscriptionRecurring,
SubscriptionStatus, SubscriptionStatus,
SubscriptionVariant,
} from './types'; } from './types';
export const CheckoutExtraArgs = z.union([
UserSubscriptionCheckoutArgs,
WorkspaceSubscriptionCheckoutArgs,
]);
export const SubscriptionIdentity = z.union([
UserSubscriptionIdentity,
WorkspaceSubscriptionIdentity,
]);
export { CheckoutParams };
@Injectable() @Injectable()
export class SubscriptionService { export class SubscriptionService {
private readonly logger = new Logger(SubscriptionService.name); private readonly logger = new Logger(SubscriptionService.name);
@@ -52,143 +74,86 @@ export class SubscriptionService {
private readonly db: PrismaClient, private readonly db: PrismaClient,
private readonly feature: FeatureManagementService, private readonly feature: FeatureManagementService,
private readonly user: UserService, private readonly user: UserService,
private readonly userManager: UserSubscriptionManager private readonly userManager: UserSubscriptionManager,
private readonly workspaceManager: WorkspaceSubscriptionManager
) {} ) {}
async listPrices(user?: CurrentUser): Promise<KnownStripePrice[]> { private select(plan: SubscriptionPlan): SubscriptionManager {
const customer = user ? await this.getOrCreateCustomer(user) : undefined; switch (plan) {
case SubscriptionPlan.Team:
// TODO(@forehalo): cache return this.workspaceManager;
const prices = await this.stripe.prices.list({ case SubscriptionPlan.Pro:
active: true, case SubscriptionPlan.AI:
limit: 100, return this.userManager;
}); default:
throw new UnsupportedSubscriptionPlan({ plan });
return this.userManager.filterPrices( }
prices.data
.map(price => this.parseStripePrice(price))
.filter(Boolean) as KnownStripePrice[],
customer
);
} }
async checkout({ async listPrices(user?: CurrentUser): Promise<KnownStripePrice[]> {
user, const prices = await this.listStripePrices();
lookupKey,
promotionCode, const customer = user
redirectUrl, ? await this.getOrCreateCustomer({
idempotencyKey, userId: user.id,
}: { userEmail: user.email,
user: CurrentUser; })
lookupKey: LookupKey; : undefined;
promotionCode?: string | null;
redirectUrl: string; return [
idempotencyKey?: string; ...(await this.userManager.filterPrices(prices, customer)),
}) { ...this.workspaceManager.filterPrices(prices, customer),
];
}
async checkout(
params: z.infer<typeof CheckoutParams>,
args: z.infer<typeof CheckoutExtraArgs>
) {
const { plan, recurring, variant } = params;
if ( if (
this.config.deploy && this.config.deploy &&
this.config.affine.canary && this.config.affine.canary &&
!this.feature.isStaff(user.email) !this.feature.isStaff(args.user.email)
) { ) {
throw new ActionForbidden(); throw new ActionForbidden();
} }
const currentSubscription = await this.userManager.getSubscription( const price = await this.getPrice({
user.id, plan,
lookupKey.plan recurring,
); variant: variant ?? null,
});
if ( if (!price) {
currentSubscription &&
// do not allow to re-subscribe unless
!(
/* current subscription is a onetime subscription and so as the one that's checking out */
(
(currentSubscription.variant === SubscriptionVariant.Onetime &&
lookupKey.variant === SubscriptionVariant.Onetime) ||
/* current subscription is normal subscription and is checking-out a lifetime subscription */
(currentSubscription.recurring !== SubscriptionRecurring.Lifetime &&
currentSubscription.variant !== SubscriptionVariant.Onetime &&
lookupKey.recurring === SubscriptionRecurring.Lifetime)
)
)
) {
throw new SubscriptionAlreadyExists({ plan: lookupKey.plan });
}
const price = await this.getPrice(lookupKey);
const customer = await this.getOrCreateCustomer(user);
const priceAndAutoCoupon = price
? await this.userManager.validatePrice(price, customer)
: null;
if (!priceAndAutoCoupon) {
throw new SubscriptionPlanNotFound({ throw new SubscriptionPlanNotFound({
plan: lookupKey.plan, plan,
recurring: lookupKey.recurring, recurring,
}); });
} }
let discounts: Stripe.Checkout.SessionCreateParams['discounts'] = []; const manager = this.select(plan);
const result = CheckoutExtraArgs.safeParse(args);
if (priceAndAutoCoupon.coupon) { if (!result.success) {
discounts = [{ coupon: priceAndAutoCoupon.coupon }]; throw new InvalidCheckoutParameters();
} else if (promotionCode) {
const coupon = await this.getCouponFromPromotionCode(
promotionCode,
customer
);
if (coupon) {
discounts = [{ coupon }];
}
} }
return await this.stripe.checkout.sessions.create( return manager.checkout(price, params, args);
{
line_items: [
{
price: priceAndAutoCoupon.price.price.id,
quantity: 1,
},
],
tax_id_collection: {
enabled: true,
},
// discount
...(discounts.length ? { discounts } : { allow_promotion_codes: true }),
// mode: 'subscription' or 'payment' for lifetime and onetime payment
...(lookupKey.recurring === SubscriptionRecurring.Lifetime ||
lookupKey.variant === SubscriptionVariant.Onetime
? {
mode: 'payment',
invoice_creation: {
enabled: true,
},
}
: {
mode: 'subscription',
}),
success_url: redirectUrl,
customer: customer.stripeCustomerId,
customer_update: {
address: 'auto',
name: 'auto',
},
},
{ idempotencyKey }
);
} }
async cancelSubscription( async cancelSubscription(
userId: string, identity: z.infer<typeof SubscriptionIdentity>,
plan: SubscriptionPlan,
idempotencyKey?: string idempotencyKey?: string
): Promise<UserSubscription> { ): Promise<Subscription> {
const subscription = await this.userManager.getSubscription(userId, plan); this.assertSubscriptionIdentity(identity);
const manager = this.select(identity.plan);
const subscription = await manager.getSubscription(identity);
if (!subscription) { if (!subscription) {
throw new SubscriptionNotExists({ plan }); throw new SubscriptionNotExists({ plan: identity.plan });
} }
if (!subscription.stripeSubscriptionId) { if (!subscription.stripeSubscriptionId) {
@@ -202,7 +167,7 @@ export class SubscriptionService {
} }
// update the subscription in db optimistically // update the subscription in db optimistically
const newSubscription = this.userManager.cancelSubscription(subscription); const newSubscription = manager.cancelSubscription(subscription);
// should release the schedule first // should release the schedule first
if (subscription.stripeScheduleId) { if (subscription.stripeScheduleId) {
@@ -224,18 +189,21 @@ export class SubscriptionService {
} }
async resumeSubscription( async resumeSubscription(
userId: string, identity: z.infer<typeof SubscriptionIdentity>,
plan: SubscriptionPlan,
idempotencyKey?: string idempotencyKey?: string
): Promise<UserSubscription> { ): Promise<Subscription> {
const subscription = await this.userManager.getSubscription(userId, plan); this.assertSubscriptionIdentity(identity);
const manager = this.select(identity.plan);
const subscription = await manager.getSubscription(identity);
if (!subscription) { if (!subscription) {
throw new SubscriptionNotExists({ plan }); throw new SubscriptionNotExists({ plan: identity.plan });
} }
if (!subscription.canceledAt) { if (!subscription.canceledAt) {
throw new SubscriptionHasBeenCanceled(); throw new SubscriptionHasNotBeenCanceled();
} }
if (!subscription.stripeSubscriptionId || !subscription.end) { if (!subscription.stripeSubscriptionId || !subscription.end) {
@@ -249,8 +217,7 @@ export class SubscriptionService {
} }
// update the subscription in db optimistically // update the subscription in db optimistically
const newSubscription = const newSubscription = await manager.resumeSubscription(subscription);
await this.userManager.resumeSubscription(subscription);
if (subscription.stripeScheduleId) { if (subscription.stripeScheduleId) {
const manager = await this.scheduleManager.fromSchedule( const manager = await this.scheduleManager.fromSchedule(
@@ -269,15 +236,17 @@ export class SubscriptionService {
} }
async updateSubscriptionRecurring( async updateSubscriptionRecurring(
userId: string, identity: z.infer<typeof SubscriptionIdentity>,
plan: SubscriptionPlan,
recurring: SubscriptionRecurring, recurring: SubscriptionRecurring,
idempotencyKey?: string idempotencyKey?: string
): Promise<UserSubscription> { ): Promise<Subscription> {
const subscription = await this.userManager.getSubscription(userId, plan); this.assertSubscriptionIdentity(identity);
const manager = this.select(identity.plan);
const subscription = await manager.getSubscription(identity);
if (!subscription) { if (!subscription) {
throw new SubscriptionNotExists({ plan }); throw new SubscriptionNotExists({ plan: identity.plan });
} }
if (!subscription.stripeSubscriptionId) { if (!subscription.stripeSubscriptionId) {
@@ -293,25 +262,29 @@ export class SubscriptionService {
} }
const price = await this.getPrice({ const price = await this.getPrice({
plan, plan: identity.plan,
recurring, recurring,
variant: null,
}); });
if (!price) { if (!price) {
throw new SubscriptionPlanNotFound({ plan, recurring }); throw new SubscriptionPlanNotFound({
plan: identity.plan,
recurring,
});
} }
// update the subscription in db optimistically // update the subscription in db optimistically
const newSubscription = this.userManager.updateSubscriptionRecurring( const newSubscription = manager.updateSubscriptionRecurring(
subscription, subscription,
recurring recurring
); );
const manager = await this.scheduleManager.fromSubscription( const scheduleManager = await this.scheduleManager.fromSubscription(
subscription.stripeSubscriptionId subscription.stripeSubscriptionId
); );
await manager.update(price.price.id, idempotencyKey); await scheduleManager.update(price.price.id, idempotencyKey);
return newSubscription; return newSubscription;
} }
@@ -339,14 +312,14 @@ export class SubscriptionService {
} }
} }
async saveStripeInvoice(stripeInvoice: Stripe.Invoice): Promise<UserInvoice> { async saveStripeInvoice(stripeInvoice: Stripe.Invoice): Promise<Invoice> {
const knownInvoice = await this.parseStripeInvoice(stripeInvoice); const knownInvoice = await this.parseStripeInvoice(stripeInvoice);
if (!knownInvoice) { if (!knownInvoice) {
throw new InternalServerError('Failed to parse stripe invoice.'); throw new InternalServerError('Failed to parse stripe invoice.');
} }
return this.userManager.saveInvoice(knownInvoice); return this.select(knownInvoice.lookupKey.plan).saveInvoice(knownInvoice);
} }
async saveStripeSubscription(subscription: Stripe.Subscription) { async saveStripeSubscription(subscription: Stripe.Subscription) {
@@ -360,10 +333,12 @@ export class SubscriptionService {
subscription.status === SubscriptionStatus.Active || subscription.status === SubscriptionStatus.Active ||
subscription.status === SubscriptionStatus.Trialing; subscription.status === SubscriptionStatus.Trialing;
const manager = this.select(knownSubscription.lookupKey.plan);
if (!isPlanActive) { if (!isPlanActive) {
await this.userManager.deleteSubscription(knownSubscription); await manager.deleteStripeSubscription(knownSubscription);
} else { } else {
await this.userManager.saveSubscription(knownSubscription); await manager.saveStripeSubscription(knownSubscription);
} }
} }
@@ -374,19 +349,26 @@ export class SubscriptionService {
throw new InternalServerError('Failed to parse stripe subscription.'); throw new InternalServerError('Failed to parse stripe subscription.');
} }
await this.userManager.deleteSubscription(knownSubscription); const manager = this.select(knownSubscription.lookupKey.plan);
await manager.deleteStripeSubscription(knownSubscription);
} }
async getOrCreateCustomer(user: CurrentUser): Promise<UserStripeCustomer> { async getOrCreateCustomer({
userId,
userEmail,
}: {
userId: string;
userEmail: string;
}): Promise<UserStripeCustomer> {
let customer = await this.db.userStripeCustomer.findUnique({ let customer = await this.db.userStripeCustomer.findUnique({
where: { where: {
userId: user.id, userId,
}, },
}); });
if (!customer) { if (!customer) {
const stripeCustomersList = await this.stripe.customers.list({ const stripeCustomersList = await this.stripe.customers.list({
email: user.email, email: userEmail,
limit: 1, limit: 1,
}); });
@@ -395,13 +377,13 @@ export class SubscriptionService {
stripeCustomer = stripeCustomersList.data[0]; stripeCustomer = stripeCustomersList.data[0];
} else { } else {
stripeCustomer = await this.stripe.customers.create({ stripeCustomer = await this.stripe.customers.create({
email: user.email, email: userEmail,
}); });
} }
customer = await this.db.userStripeCustomer.create({ customer = await this.db.userStripeCustomer.create({
data: { data: {
userId: user.id, userId,
stripeCustomerId: stripeCustomer.id, stripeCustomerId: stripeCustomer.id,
}, },
}); });
@@ -467,6 +449,17 @@ export class SubscriptionService {
return user.id; return user.id;
} }
private async listStripePrices(): Promise<KnownStripePrice[]> {
const prices = await this.stripe.prices.list({
active: true,
limit: 100,
});
return prices.data
.map(price => this.parseStripePrice(price))
.filter(Boolean) as KnownStripePrice[];
}
private async getPrice( private async getPrice(
lookupKey: LookupKey lookupKey: LookupKey
): Promise<KnownStripePrice | null> { ): Promise<KnownStripePrice | null> {
@@ -485,35 +478,6 @@ export class SubscriptionService {
: null; : null;
} }
private async getCouponFromPromotionCode(
userFacingPromotionCode: string,
customer: UserStripeCustomer
) {
const list = await this.stripe.promotionCodes.list({
code: userFacingPromotionCode,
active: true,
limit: 1,
});
const code = list.data[0];
if (!code) {
return null;
}
// the coupons are always bound to products, we need to check it first
// but the logic would be too complicated, and stripe will complain if the code is not applicable when checking out
// It's safe to skip the check here
// code.coupon.applies_to.products.forEach()
// check if the code is bound to a specific customer
return !code.customer ||
(typeof code.customer === 'string'
? code.customer === customer.stripeCustomerId
: code.customer.id === customer.stripeCustomerId)
? code.coupon.id
: null;
}
private async parseStripeInvoice( private async parseStripeInvoice(
invoice: Stripe.Invoice invoice: Stripe.Invoice
): Promise<KnownStripeInvoice | null> { ): Promise<KnownStripeInvoice | null> {
@@ -549,10 +513,13 @@ export class SubscriptionService {
userId: user.id, userId: user.id,
stripeInvoice: invoice, stripeInvoice: invoice,
lookupKey, lookupKey,
metadata: invoice.subscription_details?.metadata ?? {},
}; };
} }
private async parseStripeSubscription(subscription: Stripe.Subscription) { private async parseStripeSubscription(
subscription: Stripe.Subscription
): Promise<KnownStripeSubscription | null> {
const lookupKey = retriveLookupKeyFromStripeSubscription(subscription); const lookupKey = retriveLookupKeyFromStripeSubscription(subscription);
if (!lookupKey) { if (!lookupKey) {
@@ -569,6 +536,8 @@ export class SubscriptionService {
userId, userId,
lookupKey, lookupKey,
stripeSubscription: subscription, stripeSubscription: subscription,
quantity: subscription.items.data[0]?.quantity ?? 1,
metadata: subscription.metadata,
}; };
} }
@@ -582,4 +551,14 @@ export class SubscriptionService {
} }
: null; : null;
} }
private assertSubscriptionIdentity(
args: z.infer<typeof SubscriptionIdentity>
) {
const result = SubscriptionIdentity.safeParse(args);
if (!result.success) {
throw new InvalidSubscriptionParameters();
}
}
} }

View File

@@ -1,4 +1,4 @@
import type { User } from '@prisma/client'; import type { User, Workspace } from '@prisma/client';
import Stripe from 'stripe'; import Stripe from 'stripe';
import type { Payload } from '../../fundamentals/event/def'; import type { Payload } from '../../fundamentals/event/def';
@@ -64,12 +64,31 @@ declare module '../../fundamentals/event/def' {
}>; }>;
}; };
} }
interface WorkspaceEvents {
subscription: {
activated: Payload<{
workspaceId: Workspace['id'];
plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
quantity: number;
}>;
canceled: Payload<{
workspaceId: Workspace['id'];
plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
}>;
};
members: {
updated: Payload<{ workspaceId: Workspace['id']; count: number }>;
};
}
} }
export interface LookupKey { export interface LookupKey {
plan: SubscriptionPlan; plan: SubscriptionPlan;
recurring: SubscriptionRecurring; recurring: SubscriptionRecurring;
variant?: SubscriptionVariant; variant: SubscriptionVariant | null;
} }
export interface KnownStripeInvoice { export interface KnownStripeInvoice {
@@ -87,6 +106,11 @@ export interface KnownStripeInvoice {
* The invoice object from Stripe. * The invoice object from Stripe.
*/ */
stripeInvoice: Stripe.Invoice; stripeInvoice: Stripe.Invoice;
/**
* The metadata of the subscription related to the invoice.
*/
metadata: Record<string, string>;
} }
export interface KnownStripeSubscription { export interface KnownStripeSubscription {
@@ -104,6 +128,16 @@ export interface KnownStripeSubscription {
* The subscription object from Stripe. * The subscription object from Stripe.
*/ */
stripeSubscription: Stripe.Subscription; stripeSubscription: Stripe.Subscription;
/**
* The quantity of the subscription items.
*/
quantity: number;
/**
* The metadata of the subscription.
*/
metadata: Record<string, string>;
} }
export interface KnownStripePrice { export interface KnownStripePrice {
@@ -167,7 +201,7 @@ export function decodeLookupKey(key: string): LookupKey | null {
return { return {
plan: plan as SubscriptionPlan, plan: plan as SubscriptionPlan,
recurring: recurring as SubscriptionRecurring, recurring: recurring as SubscriptionRecurring,
variant: variant as SubscriptionVariant | undefined, variant: variant as SubscriptionVariant,
}; };
} }

View File

@@ -140,6 +140,7 @@ input CreateChatSessionInput {
} }
input CreateCheckoutSessionInput { input CreateCheckoutSessionInput {
args: JSONObject
coupon: String coupon: String
idempotencyKey: String idempotencyKey: String
plan: SubscriptionPlan = Pro plan: SubscriptionPlan = Pro
@@ -208,7 +209,7 @@ type EditorType {
name: String! name: String!
} }
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | VersionRejectedDataType union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedSubscriptionPlanDataType | VersionRejectedDataType
enum ErrorNames { enum ErrorNames {
ACCESS_DENIED ACCESS_DENIED
@@ -246,12 +247,14 @@ enum ErrorNames {
FAILED_TO_SAVE_UPDATES FAILED_TO_SAVE_UPDATES
FAILED_TO_UPSERT_SNAPSHOT FAILED_TO_UPSERT_SNAPSHOT
INTERNAL_SERVER_ERROR INTERNAL_SERVER_ERROR
INVALID_CHECKOUT_PARAMETERS
INVALID_EMAIL INVALID_EMAIL
INVALID_EMAIL_TOKEN INVALID_EMAIL_TOKEN
INVALID_HISTORY_TIMESTAMP INVALID_HISTORY_TIMESTAMP
INVALID_OAUTH_CALLBACK_STATE INVALID_OAUTH_CALLBACK_STATE
INVALID_PASSWORD_LENGTH INVALID_PASSWORD_LENGTH
INVALID_RUNTIME_CONFIG_TYPE INVALID_RUNTIME_CONFIG_TYPE
INVALID_SUBSCRIPTION_PARAMETERS
LINK_EXPIRED LINK_EXPIRED
MAILER_SERVICE_IS_NOT_CONFIGURED MAILER_SERVICE_IS_NOT_CONFIGURED
MEMBER_QUOTA_EXCEEDED MEMBER_QUOTA_EXCEEDED
@@ -273,14 +276,18 @@ enum ErrorNames {
SUBSCRIPTION_ALREADY_EXISTS SUBSCRIPTION_ALREADY_EXISTS
SUBSCRIPTION_EXPIRED SUBSCRIPTION_EXPIRED
SUBSCRIPTION_HAS_BEEN_CANCELED SUBSCRIPTION_HAS_BEEN_CANCELED
SUBSCRIPTION_HAS_NOT_BEEN_CANCELED
SUBSCRIPTION_NOT_EXISTS SUBSCRIPTION_NOT_EXISTS
SUBSCRIPTION_PLAN_NOT_FOUND SUBSCRIPTION_PLAN_NOT_FOUND
TOO_MANY_REQUEST TOO_MANY_REQUEST
UNKNOWN_OAUTH_PROVIDER UNKNOWN_OAUTH_PROVIDER
UNSPLASH_IS_NOT_CONFIGURED UNSPLASH_IS_NOT_CONFIGURED
UNSUPPORTED_SUBSCRIPTION_PLAN
USER_AVATAR_NOT_FOUND USER_AVATAR_NOT_FOUND
USER_NOT_FOUND USER_NOT_FOUND
VERSION_REJECTED VERSION_REJECTED
WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION
WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION
WRONG_SIGN_IN_CREDENTIALS WRONG_SIGN_IN_CREDENTIALS
WRONG_SIGN_IN_METHOD WRONG_SIGN_IN_METHOD
} }
@@ -444,7 +451,7 @@ type MissingOauthQueryParameterDataType {
type Mutation { type Mutation {
acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean! acceptInviteById(inviteId: String!, sendAcceptMail: Boolean, workspaceId: String!): Boolean!
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
cancelSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro): SubscriptionType! cancelSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
changeEmail(email: String!, token: String!): UserType! changeEmail(email: String!, token: String!): UserType!
changePassword(newPassword: String!, token: String!, userId: String): Boolean! changePassword(newPassword: String!, token: String!, userId: String): Boolean!
@@ -491,7 +498,7 @@ type Mutation {
"""Remove user avatar""" """Remove user avatar"""
removeAvatar: RemoveAvatar! removeAvatar: RemoveAvatar!
removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int! removeWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Int!
resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro): SubscriptionType! resumeSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
revoke(userId: String!, workspaceId: String!): Boolean! revoke(userId: String!, workspaceId: String!): Boolean!
revokePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use revokePublicPage") revokePage(pageId: String!, workspaceId: String!): Boolean! @deprecated(reason: "use revokePublicPage")
revokePublicPage(pageId: String!, workspaceId: String!): WorkspacePage! revokePublicPage(pageId: String!, workspaceId: String!): WorkspacePage!
@@ -513,7 +520,7 @@ type Mutation {
"""update multiple server runtime configurable settings""" """update multiple server runtime configurable settings"""
updateRuntimeConfigs(updates: JSONObject!): [ServerRuntimeConfigType!]! updateRuntimeConfigs(updates: JSONObject!): [ServerRuntimeConfigType!]!
updateSubscriptionRecurring(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, recurring: SubscriptionRecurring!): SubscriptionType! updateSubscriptionRecurring(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, recurring: SubscriptionRecurring!, workspaceId: String): SubscriptionType!
"""Update a user""" """Update a user"""
updateUser(id: String!, input: ManageUserInput!): UserType! updateUser(id: String!, input: ManageUserInput!): UserType!
@@ -814,6 +821,10 @@ type UnknownOauthProviderDataType {
name: String! name: String!
} }
type UnsupportedSubscriptionPlanDataType {
plan: String!
}
input UpdateUserInput { input UpdateUserInput {
"""User name""" """User name"""
name: String name: String
@@ -929,6 +940,10 @@ type WorkspaceType {
"""is current workspace initialized""" """is current workspace initialized"""
initialized: Boolean! initialized: Boolean!
"""Get user invoice count"""
invoiceCount: Int!
invoices(skip: Int, take: Int = 8): [InvoiceType!]!
"""member count of workspace""" """member count of workspace"""
memberCount: Int! memberCount: Int!
@@ -958,6 +973,9 @@ type WorkspaceType {
"""Shared pages of workspace""" """Shared pages of workspace"""
sharedPages: [String!]! @deprecated(reason: "use WorkspaceType.publicPages") sharedPages: [String!]! @deprecated(reason: "use WorkspaceType.publicPages")
"""The team subscription of the workspace, if exists."""
subscription: SubscriptionType
} }
type tokenType { type tokenType {

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,10 @@ Generated by [AVA](https://avajs.dev).
[ [
'pro_monthly', 'pro_monthly',
'pro_yearly', 'pro_yearly',
'pro_lifetime',
'ai_yearly', 'ai_yearly',
'team_monthly',
'team_yearly',
] ]
## should list normal prices for authenticated user ## should list normal prices for authenticated user
@@ -21,7 +24,22 @@ Generated by [AVA](https://avajs.dev).
[ [
'pro_monthly', 'pro_monthly',
'pro_yearly', 'pro_yearly',
'pro_lifetime',
'ai_yearly', 'ai_yearly',
'team_monthly',
'team_yearly',
]
## should not show lifetime price if not enabled
> Snapshot 1
[
'pro_monthly',
'pro_yearly',
'ai_yearly',
'team_monthly',
'team_yearly',
] ]
## should list early access prices for pro ea user ## should list early access prices for pro ea user
@@ -30,8 +48,11 @@ Generated by [AVA](https://avajs.dev).
[ [
'pro_monthly', 'pro_monthly',
'pro_lifetime',
'pro_yearly_earlyaccess', 'pro_yearly_earlyaccess',
'ai_yearly', 'ai_yearly',
'team_monthly',
'team_yearly',
] ]
## should list normal prices for pro ea user with old subscriptions ## should list normal prices for pro ea user with old subscriptions
@@ -41,7 +62,10 @@ Generated by [AVA](https://avajs.dev).
[ [
'pro_monthly', 'pro_monthly',
'pro_yearly', 'pro_yearly',
'pro_lifetime',
'ai_yearly', 'ai_yearly',
'team_monthly',
'team_yearly',
] ]
## should list early access prices for ai ea user ## should list early access prices for ai ea user
@@ -51,7 +75,10 @@ Generated by [AVA](https://avajs.dev).
[ [
'pro_monthly', 'pro_monthly',
'pro_yearly', 'pro_yearly',
'pro_lifetime',
'ai_yearly_earlyaccess', 'ai_yearly_earlyaccess',
'team_monthly',
'team_yearly',
] ]
## should list early access prices for pro and ai ea user ## should list early access prices for pro and ai ea user
@@ -60,8 +87,11 @@ Generated by [AVA](https://avajs.dev).
[ [
'pro_monthly', 'pro_monthly',
'pro_lifetime',
'pro_yearly_earlyaccess', 'pro_yearly_earlyaccess',
'ai_yearly_earlyaccess', 'ai_yearly_earlyaccess',
'team_monthly',
'team_yearly',
] ]
## should list normal prices for ai ea user with old subscriptions ## should list normal prices for ai ea user with old subscriptions
@@ -71,5 +101,21 @@ Generated by [AVA](https://avajs.dev).
[ [
'pro_monthly', 'pro_monthly',
'pro_yearly', 'pro_yearly',
'pro_lifetime',
'ai_yearly', 'ai_yearly',
'team_monthly',
'team_yearly',
]
## should be able to list prices for team
> Snapshot 1
[
'pro_monthly',
'pro_yearly',
'pro_lifetime',
'ai_yearly',
'team_monthly',
'team_yearly',
] ]

View File

@@ -23,7 +23,7 @@ const SUBSCRIPTION_CACHE_KEY = 'subscription:';
const getDefaultSubscriptionSuccessCallbackLink = ( const getDefaultSubscriptionSuccessCallbackLink = (
baseUrl: string, baseUrl: string,
plan: SubscriptionPlan | null, plan?: SubscriptionPlan | null,
scheme?: string scheme?: string
) => { ) => {
const path = const path =

View File

@@ -5,7 +5,11 @@ config:
strict: true strict: true
maybeValue: T | null maybeValue: T | null
declarationKind: interface declarationKind: interface
avoidOptionals: true avoidOptionals:
field: true
inputValue: false
object: false
defaultValue: false
preResolveTypes: true preResolveTypes: true
namingConvention: namingConvention:
enumValues: keep enumValues: keep

View File

@@ -77,8 +77,8 @@ export interface Copilot {
} }
export interface CopilotHistoriesArgs { export interface CopilotHistoriesArgs {
docId: InputMaybe<Scalars['String']['input']>; docId?: InputMaybe<Scalars['String']['input']>;
options: InputMaybe<QueryChatHistoriesInput>; options?: InputMaybe<QueryChatHistoriesInput>;
} }
export interface CopilotHistories { export interface CopilotHistories {
@@ -111,11 +111,11 @@ export enum CopilotModels {
} }
export interface CopilotPromptConfigInput { export interface CopilotPromptConfigInput {
frequencyPenalty: InputMaybe<Scalars['Float']['input']>; frequencyPenalty?: InputMaybe<Scalars['Float']['input']>;
jsonMode: InputMaybe<Scalars['Boolean']['input']>; jsonMode?: InputMaybe<Scalars['Boolean']['input']>;
presencePenalty: InputMaybe<Scalars['Float']['input']>; presencePenalty?: InputMaybe<Scalars['Float']['input']>;
temperature: InputMaybe<Scalars['Float']['input']>; temperature?: InputMaybe<Scalars['Float']['input']>;
topP: InputMaybe<Scalars['Float']['input']>; topP?: InputMaybe<Scalars['Float']['input']>;
} }
export interface CopilotPromptConfigType { export interface CopilotPromptConfigType {
@@ -129,7 +129,7 @@ export interface CopilotPromptConfigType {
export interface CopilotPromptMessageInput { export interface CopilotPromptMessageInput {
content: Scalars['String']['input']; content: Scalars['String']['input'];
params: InputMaybe<Scalars['JSON']['input']>; params?: InputMaybe<Scalars['JSON']['input']>;
role: CopilotPromptMessageRole; role: CopilotPromptMessageRole;
} }
@@ -174,10 +174,10 @@ export interface CopilotQuota {
} }
export interface CreateChatMessageInput { export interface CreateChatMessageInput {
attachments: InputMaybe<Array<Scalars['String']['input']>>; attachments?: InputMaybe<Array<Scalars['String']['input']>>;
blobs: InputMaybe<Array<Scalars['Upload']['input']>>; blobs?: InputMaybe<Array<Scalars['Upload']['input']>>;
content: InputMaybe<Scalars['String']['input']>; content?: InputMaybe<Scalars['String']['input']>;
params: InputMaybe<Scalars['JSON']['input']>; params?: InputMaybe<Scalars['JSON']['input']>;
sessionId: Scalars['String']['input']; sessionId: Scalars['String']['input'];
} }
@@ -189,17 +189,19 @@ export interface CreateChatSessionInput {
} }
export interface CreateCheckoutSessionInput { export interface CreateCheckoutSessionInput {
coupon: InputMaybe<Scalars['String']['input']>; args?: InputMaybe<Scalars['JSONObject']['input']>;
idempotencyKey: InputMaybe<Scalars['String']['input']>; coupon?: InputMaybe<Scalars['String']['input']>;
plan: InputMaybe<SubscriptionPlan>; idempotencyKey?: InputMaybe<Scalars['String']['input']>;
recurring: InputMaybe<SubscriptionRecurring>; plan?: InputMaybe<SubscriptionPlan>;
quantity?: InputMaybe<Scalars['Int']['input']>;
recurring?: InputMaybe<SubscriptionRecurring>;
successCallbackLink: Scalars['String']['input']; successCallbackLink: Scalars['String']['input'];
variant: InputMaybe<SubscriptionVariant>; variant?: InputMaybe<SubscriptionVariant>;
} }
export interface CreateCopilotPromptInput { export interface CreateCopilotPromptInput {
action: InputMaybe<Scalars['String']['input']>; action?: InputMaybe<Scalars['String']['input']>;
config: InputMaybe<CopilotPromptConfigInput>; config?: InputMaybe<CopilotPromptConfigInput>;
messages: Array<CopilotPromptMessageInput>; messages: Array<CopilotPromptMessageInput>;
model: CopilotModels; model: CopilotModels;
name: Scalars['String']['input']; name: Scalars['String']['input'];
@@ -207,7 +209,7 @@ export interface CreateCopilotPromptInput {
export interface CreateUserInput { export interface CreateUserInput {
email: Scalars['String']['input']; email: Scalars['String']['input'];
name: InputMaybe<Scalars['String']['input']>; name?: InputMaybe<Scalars['String']['input']>;
} }
export interface CredentialsRequirementType { export interface CredentialsRequirementType {
@@ -283,6 +285,7 @@ export type ErrorDataUnion =
| SubscriptionNotExistsDataType | SubscriptionNotExistsDataType
| SubscriptionPlanNotFoundDataType | SubscriptionPlanNotFoundDataType
| UnknownOauthProviderDataType | UnknownOauthProviderDataType
| UnsupportedSubscriptionPlanDataType
| VersionRejectedDataType; | VersionRejectedDataType;
export enum ErrorNames { export enum ErrorNames {
@@ -321,12 +324,14 @@ export enum ErrorNames {
FAILED_TO_SAVE_UPDATES = 'FAILED_TO_SAVE_UPDATES', FAILED_TO_SAVE_UPDATES = 'FAILED_TO_SAVE_UPDATES',
FAILED_TO_UPSERT_SNAPSHOT = 'FAILED_TO_UPSERT_SNAPSHOT', FAILED_TO_UPSERT_SNAPSHOT = 'FAILED_TO_UPSERT_SNAPSHOT',
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
INVALID_CHECKOUT_PARAMETERS = 'INVALID_CHECKOUT_PARAMETERS',
INVALID_EMAIL = 'INVALID_EMAIL', INVALID_EMAIL = 'INVALID_EMAIL',
INVALID_EMAIL_TOKEN = 'INVALID_EMAIL_TOKEN', INVALID_EMAIL_TOKEN = 'INVALID_EMAIL_TOKEN',
INVALID_HISTORY_TIMESTAMP = 'INVALID_HISTORY_TIMESTAMP', INVALID_HISTORY_TIMESTAMP = 'INVALID_HISTORY_TIMESTAMP',
INVALID_OAUTH_CALLBACK_STATE = 'INVALID_OAUTH_CALLBACK_STATE', INVALID_OAUTH_CALLBACK_STATE = 'INVALID_OAUTH_CALLBACK_STATE',
INVALID_PASSWORD_LENGTH = 'INVALID_PASSWORD_LENGTH', INVALID_PASSWORD_LENGTH = 'INVALID_PASSWORD_LENGTH',
INVALID_RUNTIME_CONFIG_TYPE = 'INVALID_RUNTIME_CONFIG_TYPE', INVALID_RUNTIME_CONFIG_TYPE = 'INVALID_RUNTIME_CONFIG_TYPE',
INVALID_SUBSCRIPTION_PARAMETERS = 'INVALID_SUBSCRIPTION_PARAMETERS',
LINK_EXPIRED = 'LINK_EXPIRED', LINK_EXPIRED = 'LINK_EXPIRED',
MAILER_SERVICE_IS_NOT_CONFIGURED = 'MAILER_SERVICE_IS_NOT_CONFIGURED', MAILER_SERVICE_IS_NOT_CONFIGURED = 'MAILER_SERVICE_IS_NOT_CONFIGURED',
MEMBER_QUOTA_EXCEEDED = 'MEMBER_QUOTA_EXCEEDED', MEMBER_QUOTA_EXCEEDED = 'MEMBER_QUOTA_EXCEEDED',
@@ -348,14 +353,18 @@ export enum ErrorNames {
SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS', SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS',
SUBSCRIPTION_EXPIRED = 'SUBSCRIPTION_EXPIRED', SUBSCRIPTION_EXPIRED = 'SUBSCRIPTION_EXPIRED',
SUBSCRIPTION_HAS_BEEN_CANCELED = 'SUBSCRIPTION_HAS_BEEN_CANCELED', SUBSCRIPTION_HAS_BEEN_CANCELED = 'SUBSCRIPTION_HAS_BEEN_CANCELED',
SUBSCRIPTION_HAS_NOT_BEEN_CANCELED = 'SUBSCRIPTION_HAS_NOT_BEEN_CANCELED',
SUBSCRIPTION_NOT_EXISTS = 'SUBSCRIPTION_NOT_EXISTS', SUBSCRIPTION_NOT_EXISTS = 'SUBSCRIPTION_NOT_EXISTS',
SUBSCRIPTION_PLAN_NOT_FOUND = 'SUBSCRIPTION_PLAN_NOT_FOUND', SUBSCRIPTION_PLAN_NOT_FOUND = 'SUBSCRIPTION_PLAN_NOT_FOUND',
TOO_MANY_REQUEST = 'TOO_MANY_REQUEST', TOO_MANY_REQUEST = 'TOO_MANY_REQUEST',
UNKNOWN_OAUTH_PROVIDER = 'UNKNOWN_OAUTH_PROVIDER', UNKNOWN_OAUTH_PROVIDER = 'UNKNOWN_OAUTH_PROVIDER',
UNSPLASH_IS_NOT_CONFIGURED = 'UNSPLASH_IS_NOT_CONFIGURED', UNSPLASH_IS_NOT_CONFIGURED = 'UNSPLASH_IS_NOT_CONFIGURED',
UNSUPPORTED_SUBSCRIPTION_PLAN = 'UNSUPPORTED_SUBSCRIPTION_PLAN',
USER_AVATAR_NOT_FOUND = 'USER_AVATAR_NOT_FOUND', USER_AVATAR_NOT_FOUND = 'USER_AVATAR_NOT_FOUND',
USER_NOT_FOUND = 'USER_NOT_FOUND', USER_NOT_FOUND = 'USER_NOT_FOUND',
VERSION_REJECTED = 'VERSION_REJECTED', VERSION_REJECTED = 'VERSION_REJECTED',
WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION = 'WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION',
WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION = 'WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION',
WRONG_SIGN_IN_CREDENTIALS = 'WRONG_SIGN_IN_CREDENTIALS', WRONG_SIGN_IN_CREDENTIALS = 'WRONG_SIGN_IN_CREDENTIALS',
WRONG_SIGN_IN_METHOD = 'WRONG_SIGN_IN_METHOD', WRONG_SIGN_IN_METHOD = 'WRONG_SIGN_IN_METHOD',
} }
@@ -491,15 +500,15 @@ export interface LimitedUserType {
} }
export interface ListUserInput { export interface ListUserInput {
first: InputMaybe<Scalars['Int']['input']>; first?: InputMaybe<Scalars['Int']['input']>;
skip: InputMaybe<Scalars['Int']['input']>; skip?: InputMaybe<Scalars['Int']['input']>;
} }
export interface ManageUserInput { export interface ManageUserInput {
/** User email */ /** User email */
email: InputMaybe<Scalars['String']['input']>; email?: InputMaybe<Scalars['String']['input']>;
/** User name */ /** User name */
name: InputMaybe<Scalars['String']['input']>; name?: InputMaybe<Scalars['String']['input']>;
} }
export interface MissingOauthQueryParameterDataType { export interface MissingOauthQueryParameterDataType {
@@ -581,7 +590,7 @@ export interface Mutation {
export interface MutationAcceptInviteByIdArgs { export interface MutationAcceptInviteByIdArgs {
inviteId: Scalars['String']['input']; inviteId: Scalars['String']['input'];
sendAcceptMail: InputMaybe<Scalars['Boolean']['input']>; sendAcceptMail?: InputMaybe<Scalars['Boolean']['input']>;
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
} }
@@ -591,8 +600,9 @@ export interface MutationAddWorkspaceFeatureArgs {
} }
export interface MutationCancelSubscriptionArgs { export interface MutationCancelSubscriptionArgs {
idempotencyKey: InputMaybe<Scalars['String']['input']>; idempotencyKey?: InputMaybe<Scalars['String']['input']>;
plan?: InputMaybe<SubscriptionPlan>; plan?: InputMaybe<SubscriptionPlan>;
workspaceId?: InputMaybe<Scalars['String']['input']>;
} }
export interface MutationChangeEmailArgs { export interface MutationChangeEmailArgs {
@@ -603,7 +613,7 @@ export interface MutationChangeEmailArgs {
export interface MutationChangePasswordArgs { export interface MutationChangePasswordArgs {
newPassword: Scalars['String']['input']; newPassword: Scalars['String']['input'];
token: Scalars['String']['input']; token: Scalars['String']['input'];
userId: InputMaybe<Scalars['String']['input']>; userId?: InputMaybe<Scalars['String']['input']>;
} }
export interface MutationCleanupCopilotSessionArgs { export interface MutationCleanupCopilotSessionArgs {
@@ -636,7 +646,7 @@ export interface MutationCreateUserArgs {
} }
export interface MutationCreateWorkspaceArgs { export interface MutationCreateWorkspaceArgs {
init: InputMaybe<Scalars['Upload']['input']>; init?: InputMaybe<Scalars['Upload']['input']>;
} }
export interface MutationDeleteBlobArgs { export interface MutationDeleteBlobArgs {
@@ -659,12 +669,12 @@ export interface MutationForkCopilotSessionArgs {
export interface MutationInviteArgs { export interface MutationInviteArgs {
email: Scalars['String']['input']; email: Scalars['String']['input'];
permission: Permission; permission: Permission;
sendInviteMail: InputMaybe<Scalars['Boolean']['input']>; sendInviteMail?: InputMaybe<Scalars['Boolean']['input']>;
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
} }
export interface MutationLeaveWorkspaceArgs { export interface MutationLeaveWorkspaceArgs {
sendLeaveMail: InputMaybe<Scalars['Boolean']['input']>; sendLeaveMail?: InputMaybe<Scalars['Boolean']['input']>;
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
workspaceName: Scalars['String']['input']; workspaceName: Scalars['String']['input'];
} }
@@ -687,8 +697,9 @@ export interface MutationRemoveWorkspaceFeatureArgs {
} }
export interface MutationResumeSubscriptionArgs { export interface MutationResumeSubscriptionArgs {
idempotencyKey: InputMaybe<Scalars['String']['input']>; idempotencyKey?: InputMaybe<Scalars['String']['input']>;
plan?: InputMaybe<SubscriptionPlan>; plan?: InputMaybe<SubscriptionPlan>;
workspaceId?: InputMaybe<Scalars['String']['input']>;
} }
export interface MutationRevokeArgs { export interface MutationRevokeArgs {
@@ -708,17 +719,17 @@ export interface MutationRevokePublicPageArgs {
export interface MutationSendChangeEmailArgs { export interface MutationSendChangeEmailArgs {
callbackUrl: Scalars['String']['input']; callbackUrl: Scalars['String']['input'];
email: InputMaybe<Scalars['String']['input']>; email?: InputMaybe<Scalars['String']['input']>;
} }
export interface MutationSendChangePasswordEmailArgs { export interface MutationSendChangePasswordEmailArgs {
callbackUrl: Scalars['String']['input']; callbackUrl: Scalars['String']['input'];
email: InputMaybe<Scalars['String']['input']>; email?: InputMaybe<Scalars['String']['input']>;
} }
export interface MutationSendSetPasswordEmailArgs { export interface MutationSendSetPasswordEmailArgs {
callbackUrl: Scalars['String']['input']; callbackUrl: Scalars['String']['input'];
email: InputMaybe<Scalars['String']['input']>; email?: InputMaybe<Scalars['String']['input']>;
} }
export interface MutationSendVerifyChangeEmailArgs { export interface MutationSendVerifyChangeEmailArgs {
@@ -766,9 +777,10 @@ export interface MutationUpdateRuntimeConfigsArgs {
} }
export interface MutationUpdateSubscriptionRecurringArgs { export interface MutationUpdateSubscriptionRecurringArgs {
idempotencyKey: InputMaybe<Scalars['String']['input']>; idempotencyKey?: InputMaybe<Scalars['String']['input']>;
plan?: InputMaybe<SubscriptionPlan>; plan?: InputMaybe<SubscriptionPlan>;
recurring: SubscriptionRecurring; recurring: SubscriptionRecurring;
workspaceId?: InputMaybe<Scalars['String']['input']>;
} }
export interface MutationUpdateUserArgs { export interface MutationUpdateUserArgs {
@@ -845,7 +857,6 @@ export interface Query {
/** List all copilot prompts */ /** List all copilot prompts */
listCopilotPrompts: Array<CopilotPromptType>; listCopilotPrompts: Array<CopilotPromptType>;
listWorkspaceFeatures: Array<WorkspaceType>; listWorkspaceFeatures: Array<WorkspaceType>;
/** @deprecated use `userPrices` instead */
prices: Array<SubscriptionPrice>; prices: Array<SubscriptionPrice>;
/** server config */ /** server config */
serverConfig: ServerConfigType; serverConfig: ServerConfigType;
@@ -914,13 +925,13 @@ export interface QueryWorkspaceArgs {
} }
export interface QueryChatHistoriesInput { export interface QueryChatHistoriesInput {
action: InputMaybe<Scalars['Boolean']['input']>; action?: InputMaybe<Scalars['Boolean']['input']>;
fork: InputMaybe<Scalars['Boolean']['input']>; fork?: InputMaybe<Scalars['Boolean']['input']>;
limit: InputMaybe<Scalars['Int']['input']>; limit?: InputMaybe<Scalars['Int']['input']>;
messageOrder: InputMaybe<ChatHistoryOrder>; messageOrder?: InputMaybe<ChatHistoryOrder>;
sessionId: InputMaybe<Scalars['String']['input']>; sessionId?: InputMaybe<Scalars['String']['input']>;
sessionOrder: InputMaybe<ChatHistoryOrder>; sessionOrder?: InputMaybe<ChatHistoryOrder>;
skip: InputMaybe<Scalars['Int']['input']>; skip?: InputMaybe<Scalars['Int']['input']>;
} }
export interface QuotaQueryType { export interface QuotaQueryType {
@@ -1123,17 +1134,22 @@ export interface UnknownOauthProviderDataType {
name: Scalars['String']['output']; name: Scalars['String']['output'];
} }
export interface UnsupportedSubscriptionPlanDataType {
__typename?: 'UnsupportedSubscriptionPlanDataType';
plan: Scalars['String']['output'];
}
export interface UpdateUserInput { export interface UpdateUserInput {
/** User name */ /** User name */
name: InputMaybe<Scalars['String']['input']>; name?: InputMaybe<Scalars['String']['input']>;
} }
export interface UpdateWorkspaceInput { export interface UpdateWorkspaceInput {
/** Enable url previous when sharing */ /** Enable url previous when sharing */
enableUrlPreview: InputMaybe<Scalars['Boolean']['input']>; enableUrlPreview?: InputMaybe<Scalars['Boolean']['input']>;
id: Scalars['ID']['input']; id: Scalars['ID']['input'];
/** is Public workspace */ /** is Public workspace */
public: InputMaybe<Scalars['Boolean']['input']>; public?: InputMaybe<Scalars['Boolean']['input']>;
} }
export type UserOrLimitedUser = LimitedUserType | UserType; export type UserOrLimitedUser = LimitedUserType | UserType;
@@ -1188,11 +1204,11 @@ export interface UserType {
} }
export interface UserTypeCopilotArgs { export interface UserTypeCopilotArgs {
workspaceId: InputMaybe<Scalars['String']['input']>; workspaceId?: InputMaybe<Scalars['String']['input']>;
} }
export interface UserTypeInvoicesArgs { export interface UserTypeInvoicesArgs {
skip: InputMaybe<Scalars['Int']['input']>; skip?: InputMaybe<Scalars['Int']['input']>;
take?: InputMaybe<Scalars['Int']['input']>; take?: InputMaybe<Scalars['Int']['input']>;
} }
@@ -1264,17 +1280,19 @@ export interface WorkspaceType {
* @deprecated use WorkspaceType.publicPages * @deprecated use WorkspaceType.publicPages
*/ */
sharedPages: Array<Scalars['String']['output']>; sharedPages: Array<Scalars['String']['output']>;
/** The team subscription of the workspace, if exists. */
subscription: Maybe<SubscriptionType>;
} }
export interface WorkspaceTypeHistoriesArgs { export interface WorkspaceTypeHistoriesArgs {
before: InputMaybe<Scalars['DateTime']['input']>; before?: InputMaybe<Scalars['DateTime']['input']>;
guid: Scalars['String']['input']; guid: Scalars['String']['input'];
take: InputMaybe<Scalars['Int']['input']>; take?: InputMaybe<Scalars['Int']['input']>;
} }
export interface WorkspaceTypeMembersArgs { export interface WorkspaceTypeMembersArgs {
skip: InputMaybe<Scalars['Int']['input']>; skip?: InputMaybe<Scalars['Int']['input']>;
take: InputMaybe<Scalars['Int']['input']>; take?: InputMaybe<Scalars['Int']['input']>;
} }
export interface WorkspaceTypePageMetaArgs { export interface WorkspaceTypePageMetaArgs {
@@ -1519,8 +1537,8 @@ export type PasswordLimitsFragment = {
export type GetCopilotHistoriesQueryVariables = Exact<{ export type GetCopilotHistoriesQueryVariables = Exact<{
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
docId: InputMaybe<Scalars['String']['input']>; docId?: InputMaybe<Scalars['String']['input']>;
options: InputMaybe<QueryChatHistoriesInput>; options?: InputMaybe<QueryChatHistoriesInput>;
}>; }>;
export type GetCopilotHistoriesQuery = { export type GetCopilotHistoriesQuery = {
@@ -1550,8 +1568,8 @@ export type GetCopilotHistoriesQuery = {
export type GetCopilotHistoryIdsQueryVariables = Exact<{ export type GetCopilotHistoryIdsQueryVariables = Exact<{
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
docId: InputMaybe<Scalars['String']['input']>; docId?: InputMaybe<Scalars['String']['input']>;
options: InputMaybe<QueryChatHistoriesInput>; options?: InputMaybe<QueryChatHistoriesInput>;
}>; }>;
export type GetCopilotHistoryIdsQuery = { export type GetCopilotHistoryIdsQuery = {
@@ -1921,8 +1939,8 @@ export type GetWorkspacesQuery = {
export type ListHistoryQueryVariables = Exact<{ export type ListHistoryQueryVariables = Exact<{
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
pageDocId: Scalars['String']['input']; pageDocId: Scalars['String']['input'];
take: InputMaybe<Scalars['Int']['input']>; take?: InputMaybe<Scalars['Int']['input']>;
before: InputMaybe<Scalars['DateTime']['input']>; before?: InputMaybe<Scalars['DateTime']['input']>;
}>; }>;
export type ListHistoryQuery = { export type ListHistoryQuery = {
@@ -1976,7 +1994,7 @@ export type InvoicesQuery = {
export type LeaveWorkspaceMutationVariables = Exact<{ export type LeaveWorkspaceMutationVariables = Exact<{
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
workspaceName: Scalars['String']['input']; workspaceName: Scalars['String']['input'];
sendLeaveMail: InputMaybe<Scalars['Boolean']['input']>; sendLeaveMail?: InputMaybe<Scalars['Boolean']['input']>;
}>; }>;
export type LeaveWorkspaceMutation = { export type LeaveWorkspaceMutation = {
@@ -2428,7 +2446,7 @@ export type InviteByEmailMutationVariables = Exact<{
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
email: Scalars['String']['input']; email: Scalars['String']['input'];
permission: Permission; permission: Permission;
sendInviteMail: InputMaybe<Scalars['Boolean']['input']>; sendInviteMail?: InputMaybe<Scalars['Boolean']['input']>;
}>; }>;
export type InviteByEmailMutation = { __typename?: 'Mutation'; invite: string }; export type InviteByEmailMutation = { __typename?: 'Mutation'; invite: string };
@@ -2436,7 +2454,7 @@ export type InviteByEmailMutation = { __typename?: 'Mutation'; invite: string };
export type AcceptInviteByInviteIdMutationVariables = Exact<{ export type AcceptInviteByInviteIdMutationVariables = Exact<{
workspaceId: Scalars['String']['input']; workspaceId: Scalars['String']['input'];
inviteId: Scalars['String']['input']; inviteId: Scalars['String']['input'];
sendAcceptMail: InputMaybe<Scalars['Boolean']['input']>; sendAcceptMail?: InputMaybe<Scalars['Boolean']['input']>;
}>; }>;
export type AcceptInviteByInviteIdMutation = { export type AcceptInviteByInviteIdMutation = {