mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 12:28:42 +00:00
feat(server): support team workspace subscription (#8919)
close AF-1724, AF-1722
This commit is contained in:
@@ -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");
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -37,4 +37,4 @@ import {
|
|||||||
})
|
})
|
||||||
export class WorkspaceModule {}
|
export class WorkspaceModule {}
|
||||||
|
|
||||||
export type { InvitationType, WorkspaceType } from './types';
|
export { InvitationType, WorkspaceType } from './types';
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,3 +36,5 @@ export class ConfigModule {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { Runtime };
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './common';
|
export * from './common';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
|
export * from './workspace';
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
305
packages/backend/server/src/plugins/payment/manager/workspace.ts
Normal file
305
packages/backend/server/src/plugins/payment/manager/workspace.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
]
|
]
|
||||||
|
|||||||
Binary file not shown.
@@ -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 =
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user