gergo/web 2038 billing graphql api (#3379)

* feat(gatekeeper): add gatekeeper module feature flag

* feat(gatekeeper): add workspace pricing table domain

* feat(gatekeeper): add checkout session creation

* feat(gatekeeper): verify stripe signature

* wip(gatekeeper): checkout callbacks

* feat(gatekeeper): add unlimited and academia plan types

* refactor(envHelper): getStringFromEnv helper

* chore(gatekeeper): add future todos

* feat(gatekeeper): add productId to the subscription domain

* feat(gatekeeper): add in memory repositories

* feat(gatekeeper): add more errors

* feat(gatekeeper): complete checkout session service

* feat(gatekeeper): add stripe client implementation

* feat(gatekeeper): add checkout session completion webhook callback path

* feat(gendo): fix not needing env vars if gendo module is not enabled

* feat(gatekeeper): require a license for billing

* chore(gatekeeper): cleanup before testing

* feat(gatekeeper): subscriptionData parsing model

* ci: add billing integration and gatekeeper modules to test config

* test(gatekeeper): add checkout service tests

* feat(gatekeeper): make completeCheckout callback idempotent properly

* feat(gatekeeper): move to knex based repositories

* test(gatekeeper): billing repository tests

* feat(gatekeeper): add yearly billing cycle toggle

* feat(ci): add stripe integration context to test job

* feat(billingPage): conditionally render the checkout CTAs

* fix(gatekeeper): remove flaky test condition

* feat(helm): add billing integration feature flag

* WIP billing gql api

* feat(gatekeeper): cancel checkout session api

* feat(gatekeeper): handle existing checkout sessions, when trying to create a new one

* feat(gatekeeper): add workspace plans gql api

* feat(gatekeeper): handle cancelation and subscription updates

* fix(gatekeeper): scope initialization

* fix(gatekeeper): eliminate stripe client import sideeffect

* fix(gatekeeper): eliminate stripe client import sideeffect 2

* fix(mainConstants): fitler gatekeeper scopes with feature flag
This commit is contained in:
Gergő Jedlicska 2024-10-25 10:46:09 +02:00 коммит произвёл GitHub
Родитель d4241b04f5
Коммит af3857a209
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
24 изменённых файлов: 1742 добавлений и 167 удалений

Просмотреть файл

@ -428,6 +428,11 @@ export type BasicGitRepositoryMetadata = {
url: Scalars['String']['output'];
};
export enum BillingInterval {
Monthly = 'monthly',
Yearly = 'yearly'
}
export type BlobMetadata = {
__typename?: 'BlobMetadata';
createdAt: Scalars['DateTime']['output'];
@ -505,6 +510,28 @@ export type BranchUpdateInput = {
streamId: Scalars['String']['input'];
};
export type CancelCheckoutSessionInput = {
sessionId: Scalars['ID']['input'];
workspaceId: Scalars['ID']['input'];
};
export type CheckoutSession = {
__typename?: 'CheckoutSession';
billingInterval: BillingInterval;
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
paymentStatus: SessionPaymentStatus;
updatedAt: Scalars['DateTime']['output'];
url: Scalars['String']['output'];
workspacePlan: PaidWorkspacePlans;
};
export type CheckoutSessionInput = {
billingInterval: BillingInterval;
workspaceId: Scalars['ID']['input'];
workspacePlan: PaidWorkspacePlans;
};
export type Comment = {
__typename?: 'Comment';
archived: Scalars['Boolean']['output'];
@ -1709,6 +1736,12 @@ export type ObjectCreateInput = {
streamId: Scalars['String']['input'];
};
export enum PaidWorkspacePlans {
Business = 'business',
Pro = 'pro',
Team = 'team'
}
export type PasswordStrengthCheckFeedback = {
__typename?: 'PasswordStrengthCheckFeedback';
suggestions: Array<Scalars['String']['output']>;
@ -2852,6 +2885,11 @@ export type ServerWorkspacesInfo = {
workspacesEnabled: Scalars['Boolean']['output'];
};
export enum SessionPaymentStatus {
Paid = 'paid',
Unpaid = 'unpaid'
}
export type SetPrimaryUserEmailInput = {
id: Scalars['ID']['input'];
};
@ -3889,6 +3927,7 @@ export type Workspace = {
/** Billing data for Workspaces beta */
billing?: Maybe<WorkspaceBilling>;
createdAt: Scalars['DateTime']['output'];
customerPortalUrl?: Maybe<Scalars['String']['output']>;
/** Selected fallback when `logo` not set */
defaultLogoIndex: Scalars['Int']['output'];
/** The default role workspace members will receive for workspace projects. */
@ -3906,10 +3945,12 @@ export type Workspace = {
/** Logo image as base64-encoded string */
logo?: Maybe<Scalars['String']['output']>;
name: Scalars['String']['output'];
plan?: Maybe<WorkspacePlan>;
projects: ProjectCollection;
/** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */
role?: Maybe<Scalars['String']['output']>;
slug: Scalars['String']['output'];
subscription?: Maybe<WorkspaceSubscription>;
team: WorkspaceCollaboratorCollection;
updatedAt: Scalars['DateTime']['output'];
};
@ -3939,6 +3980,22 @@ export type WorkspaceBilling = {
versionsCount: WorkspaceVersionsCount;
};
export type WorkspaceBillingMutations = {
__typename?: 'WorkspaceBillingMutations';
cancelCheckoutSession: Scalars['Boolean']['output'];
createCheckoutSession: CheckoutSession;
};
export type WorkspaceBillingMutationsCancelCheckoutSessionArgs = {
input: CancelCheckoutSessionInput;
};
export type WorkspaceBillingMutationsCreateCheckoutSessionArgs = {
input: CheckoutSessionInput;
};
/** Overridden by `WorkspaceCollaboratorGraphQLReturn` */
export type WorkspaceCollaborator = {
__typename?: 'WorkspaceCollaborator';
@ -4080,6 +4137,7 @@ export type WorkspaceInviteUseInput = {
export type WorkspaceMutations = {
__typename?: 'WorkspaceMutations';
addDomain: Workspace;
billing: WorkspaceBillingMutations;
create: Workspace;
delete: Scalars['Boolean']['output'];
deleteDomain: Workspace;
@ -4131,6 +4189,27 @@ export type WorkspaceMutationsUpdateRoleArgs = {
input: WorkspaceRoleUpdateInput;
};
export type WorkspacePlan = {
__typename?: 'WorkspacePlan';
name: WorkspacePlans;
status: WorkspacePlanStatuses;
};
export enum WorkspacePlanStatuses {
Canceled = 'canceled',
PaymentFailed = 'paymentFailed',
Trial = 'trial',
Valid = 'valid'
}
export enum WorkspacePlans {
Academia = 'academia',
Business = 'business',
Pro = 'pro',
Team = 'team',
Unlimited = 'unlimited'
}
export type WorkspaceProjectInviteCreateInput = {
/** Either this or userId must be filled */
email?: InputMaybe<Scalars['String']['input']>;
@ -4184,6 +4263,14 @@ export type WorkspaceRoleUpdateInput = {
workspaceId: Scalars['String']['input'];
};
export type WorkspaceSubscription = {
__typename?: 'WorkspaceSubscription';
billingInterval: BillingInterval;
createdAt: Scalars['DateTime']['output'];
currentBillingCycleEnd: Scalars['DateTime']['output'];
updatedAt: Scalars['DateTime']['output'];
};
export type WorkspaceTeamFilter = {
/** Limit team members to provided role(s) */
roles?: InputMaybe<Array<Scalars['String']['input']>>;
@ -6142,6 +6229,7 @@ export type AllObjectTypes = {
BlobMetadataCollection: BlobMetadataCollection,
Branch: Branch,
BranchCollection: BranchCollection,
CheckoutSession: CheckoutSession,
Comment: Comment,
CommentActivityMessage: CommentActivityMessage,
CommentCollection: CommentCollection,
@ -6236,6 +6324,7 @@ export type AllObjectTypes = {
WebhookEventCollection: WebhookEventCollection,
Workspace: Workspace,
WorkspaceBilling: WorkspaceBilling,
WorkspaceBillingMutations: WorkspaceBillingMutations,
WorkspaceCollaborator: WorkspaceCollaborator,
WorkspaceCollaboratorCollection: WorkspaceCollaboratorCollection,
WorkspaceCollection: WorkspaceCollection,
@ -6245,7 +6334,9 @@ export type AllObjectTypes = {
WorkspaceDomain: WorkspaceDomain,
WorkspaceInviteMutations: WorkspaceInviteMutations,
WorkspaceMutations: WorkspaceMutations,
WorkspacePlan: WorkspacePlan,
WorkspaceProjectMutations: WorkspaceProjectMutations,
WorkspaceSubscription: WorkspaceSubscription,
WorkspaceVersionsCount: WorkspaceVersionsCount,
}
export type ActiveUserMutationsFieldArgs = {
@ -6463,6 +6554,15 @@ export type BranchCollectionFieldArgs = {
items: {},
totalCount: {},
}
export type CheckoutSessionFieldArgs = {
billingInterval: {},
createdAt: {},
id: {},
paymentStatus: {},
updatedAt: {},
url: {},
workspacePlan: {},
}
export type CommentFieldArgs = {
archived: {},
author: {},
@ -7300,6 +7400,7 @@ export type WebhookEventCollectionFieldArgs = {
export type WorkspaceFieldArgs = {
billing: {},
createdAt: {},
customerPortalUrl: {},
defaultLogoIndex: {},
defaultProjectRole: {},
description: {},
@ -7310,9 +7411,11 @@ export type WorkspaceFieldArgs = {
invitedTeam: WorkspaceInvitedTeamArgs,
logo: {},
name: {},
plan: {},
projects: WorkspaceProjectsArgs,
role: {},
slug: {},
subscription: {},
team: WorkspaceTeamArgs,
updatedAt: {},
}
@ -7320,6 +7423,10 @@ export type WorkspaceBillingFieldArgs = {
cost: {},
versionsCount: {},
}
export type WorkspaceBillingMutationsFieldArgs = {
cancelCheckoutSession: WorkspaceBillingMutationsCancelCheckoutSessionArgs,
createCheckoutSession: WorkspaceBillingMutationsCreateCheckoutSessionArgs,
}
export type WorkspaceCollaboratorFieldArgs = {
id: {},
projectRoles: {},
@ -7366,6 +7473,7 @@ export type WorkspaceInviteMutationsFieldArgs = {
}
export type WorkspaceMutationsFieldArgs = {
addDomain: WorkspaceMutationsAddDomainArgs,
billing: {},
create: WorkspaceMutationsCreateArgs,
delete: WorkspaceMutationsDeleteArgs,
deleteDomain: WorkspaceMutationsDeleteDomainArgs,
@ -7376,10 +7484,20 @@ export type WorkspaceMutationsFieldArgs = {
update: WorkspaceMutationsUpdateArgs,
updateRole: WorkspaceMutationsUpdateRoleArgs,
}
export type WorkspacePlanFieldArgs = {
name: {},
status: {},
}
export type WorkspaceProjectMutationsFieldArgs = {
moveToWorkspace: WorkspaceProjectMutationsMoveToWorkspaceArgs,
updateRole: WorkspaceProjectMutationsUpdateRoleArgs,
}
export type WorkspaceSubscriptionFieldArgs = {
billingInterval: {},
createdAt: {},
currentBillingCycleEnd: {},
updatedAt: {},
}
export type WorkspaceVersionsCountFieldArgs = {
current: {},
max: {},
@ -7416,6 +7534,7 @@ export type AllObjectFieldArgTypes = {
BlobMetadataCollection: BlobMetadataCollectionFieldArgs,
Branch: BranchFieldArgs,
BranchCollection: BranchCollectionFieldArgs,
CheckoutSession: CheckoutSessionFieldArgs,
Comment: CommentFieldArgs,
CommentActivityMessage: CommentActivityMessageFieldArgs,
CommentCollection: CommentCollectionFieldArgs,
@ -7510,6 +7629,7 @@ export type AllObjectFieldArgTypes = {
WebhookEventCollection: WebhookEventCollectionFieldArgs,
Workspace: WorkspaceFieldArgs,
WorkspaceBilling: WorkspaceBillingFieldArgs,
WorkspaceBillingMutations: WorkspaceBillingMutationsFieldArgs,
WorkspaceCollaborator: WorkspaceCollaboratorFieldArgs,
WorkspaceCollaboratorCollection: WorkspaceCollaboratorCollectionFieldArgs,
WorkspaceCollection: WorkspaceCollectionFieldArgs,
@ -7519,7 +7639,9 @@ export type AllObjectFieldArgTypes = {
WorkspaceDomain: WorkspaceDomainFieldArgs,
WorkspaceInviteMutations: WorkspaceInviteMutationsFieldArgs,
WorkspaceMutations: WorkspaceMutationsFieldArgs,
WorkspacePlan: WorkspacePlanFieldArgs,
WorkspaceProjectMutations: WorkspaceProjectMutationsFieldArgs,
WorkspaceSubscription: WorkspaceSubscriptionFieldArgs,
WorkspaceVersionsCount: WorkspaceVersionsCountFieldArgs,
}

Просмотреть файл

@ -1,3 +1,85 @@
extend type Query {
workspacePricingPlans: JSONObject!
}
extend type WorkspaceMutations {
billing: WorkspaceBillingMutations! @hasScope(scope: "workspace:billing")
}
enum PaidWorkspacePlans {
team
pro
business
}
enum BillingInterval {
monthly
yearly
}
enum SessionPaymentStatus {
paid
unpaid
}
input CheckoutSessionInput {
workspaceId: ID!
workspacePlan: PaidWorkspacePlans!
billingInterval: BillingInterval!
}
type CheckoutSession {
id: ID!
url: String!
workspacePlan: PaidWorkspacePlans!
paymentStatus: SessionPaymentStatus!
billingInterval: BillingInterval!
createdAt: DateTime!
updatedAt: DateTime!
}
input CancelCheckoutSessionInput {
sessionId: ID!
workspaceId: ID!
}
type WorkspaceBillingMutations {
createCheckoutSession(input: CheckoutSessionInput!): CheckoutSession!
cancelCheckoutSession(input: CancelCheckoutSessionInput!): Boolean!
}
enum WorkspacePlans {
team
pro
business
unlimited
academia
}
enum WorkspacePlanStatuses {
valid
paymentFailed
canceled
trial
}
type WorkspacePlan {
name: WorkspacePlans!
status: WorkspacePlanStatuses!
}
type WorkspaceSubscription {
createdAt: DateTime!
updatedAt: DateTime!
currentBillingCycleEnd: DateTime!
billingInterval: BillingInterval!
}
extend type Workspace {
# for now, this is nullable, cause existing workspaces have not been migrated to plans
# this doesn't need a special token scope
plan: WorkspacePlan
subscription: WorkspaceSubscription @hasScope(scope: "workspace:billing")
# this can only be created if there is an active subscription
customerPortalUrl: String @hasScope(scope: "workspace:billing")
}

Просмотреть файл

@ -61,6 +61,7 @@ generates:
WorkspaceMutations: '@/modules/workspacesCore/helpers/graphTypes#WorkspaceMutationsGraphQLReturn'
WorkspaceInviteMutations: '@/modules/workspacesCore/helpers/graphTypes#WorkspaceInviteMutationsGraphQLReturn'
WorkspaceProjectMutations: '@/modules/workspacesCore/helpers/graphTypes#WorkspaceProjectMutationsGraphQLReturn'
WorkspaceBillingMutations: '@/modules/gatekeeper/helpers/graphTypes#WorkspaceBillingMutationsGraphQLReturn'
PendingWorkspaceCollaborator: '@/modules/workspacesCore/helpers/graphTypes#PendingWorkspaceCollaboratorGraphQLReturn'
WorkspaceCollaborator: '@/modules/workspacesCore/helpers/graphTypes#WorkspaceCollaboratorGraphQLReturn'
Webhook: '@/modules/webhooks/helpers/graphTypes#WebhookGraphQLReturn'

Просмотреть файл

@ -6,6 +6,7 @@ import { PendingStreamCollaboratorGraphQLReturn } from '@/modules/serverinvites/
import { FileUploadGraphQLReturn } from '@/modules/fileuploads/helpers/types';
import { AutomateFunctionGraphQLReturn, AutomateFunctionReleaseGraphQLReturn, AutomationGraphQLReturn, AutomationRevisionGraphQLReturn, AutomationRevisionFunctionGraphQLReturn, AutomateRunGraphQLReturn, AutomationRunTriggerGraphQLReturn, AutomationRevisionTriggerDefinitionGraphQLReturn, AutomateFunctionRunGraphQLReturn, TriggeredAutomationsStatusGraphQLReturn, ProjectAutomationMutationsGraphQLReturn, ProjectTriggeredAutomationsStatusUpdatedMessageGraphQLReturn, ProjectAutomationsUpdatedMessageGraphQLReturn, UserAutomateInfoGraphQLReturn } from '@/modules/automate/helpers/graphTypes';
import { WorkspaceGraphQLReturn, WorkspaceBillingGraphQLReturn, WorkspaceMutationsGraphQLReturn, WorkspaceInviteMutationsGraphQLReturn, WorkspaceProjectMutationsGraphQLReturn, PendingWorkspaceCollaboratorGraphQLReturn, WorkspaceCollaboratorGraphQLReturn, ProjectRoleGraphQLReturn } from '@/modules/workspacesCore/helpers/graphTypes';
import { WorkspaceBillingMutationsGraphQLReturn } from '@/modules/gatekeeper/helpers/graphTypes';
import { WebhookGraphQLReturn } from '@/modules/webhooks/helpers/graphTypes';
import { SmartTextEditorValueSchema } from '@/modules/core/services/richTextEditorService';
import { BlobStorageItem } from '@/modules/blobstorage/domain/types';
@ -442,6 +443,11 @@ export type BasicGitRepositoryMetadata = {
url: Scalars['String']['output'];
};
export enum BillingInterval {
Monthly = 'monthly',
Yearly = 'yearly'
}
export type BlobMetadata = {
__typename?: 'BlobMetadata';
createdAt: Scalars['DateTime']['output'];
@ -519,6 +525,28 @@ export type BranchUpdateInput = {
streamId: Scalars['String']['input'];
};
export type CancelCheckoutSessionInput = {
sessionId: Scalars['ID']['input'];
workspaceId: Scalars['ID']['input'];
};
export type CheckoutSession = {
__typename?: 'CheckoutSession';
billingInterval: BillingInterval;
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
paymentStatus: SessionPaymentStatus;
updatedAt: Scalars['DateTime']['output'];
url: Scalars['String']['output'];
workspacePlan: PaidWorkspacePlans;
};
export type CheckoutSessionInput = {
billingInterval: BillingInterval;
workspaceId: Scalars['ID']['input'];
workspacePlan: PaidWorkspacePlans;
};
export type Comment = {
__typename?: 'Comment';
archived: Scalars['Boolean']['output'];
@ -1728,6 +1756,12 @@ export type ObjectCreateInput = {
streamId: Scalars['String']['input'];
};
export enum PaidWorkspacePlans {
Business = 'business',
Pro = 'pro',
Team = 'team'
}
export type PasswordStrengthCheckFeedback = {
__typename?: 'PasswordStrengthCheckFeedback';
suggestions: Array<Scalars['String']['output']>;
@ -2871,6 +2905,11 @@ export type ServerWorkspacesInfo = {
workspacesEnabled: Scalars['Boolean']['output'];
};
export enum SessionPaymentStatus {
Paid = 'paid',
Unpaid = 'unpaid'
}
export type SetPrimaryUserEmailInput = {
id: Scalars['ID']['input'];
};
@ -3908,6 +3947,7 @@ export type Workspace = {
/** Billing data for Workspaces beta */
billing?: Maybe<WorkspaceBilling>;
createdAt: Scalars['DateTime']['output'];
customerPortalUrl?: Maybe<Scalars['String']['output']>;
/** Selected fallback when `logo` not set */
defaultLogoIndex: Scalars['Int']['output'];
/** The default role workspace members will receive for workspace projects. */
@ -3925,10 +3965,12 @@ export type Workspace = {
/** Logo image as base64-encoded string */
logo?: Maybe<Scalars['String']['output']>;
name: Scalars['String']['output'];
plan?: Maybe<WorkspacePlan>;
projects: ProjectCollection;
/** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */
role?: Maybe<Scalars['String']['output']>;
slug: Scalars['String']['output'];
subscription?: Maybe<WorkspaceSubscription>;
team: WorkspaceCollaboratorCollection;
updatedAt: Scalars['DateTime']['output'];
};
@ -3958,6 +4000,22 @@ export type WorkspaceBilling = {
versionsCount: WorkspaceVersionsCount;
};
export type WorkspaceBillingMutations = {
__typename?: 'WorkspaceBillingMutations';
cancelCheckoutSession: Scalars['Boolean']['output'];
createCheckoutSession: CheckoutSession;
};
export type WorkspaceBillingMutationsCancelCheckoutSessionArgs = {
input: CancelCheckoutSessionInput;
};
export type WorkspaceBillingMutationsCreateCheckoutSessionArgs = {
input: CheckoutSessionInput;
};
/** Overridden by `WorkspaceCollaboratorGraphQLReturn` */
export type WorkspaceCollaborator = {
__typename?: 'WorkspaceCollaborator';
@ -4099,6 +4157,7 @@ export type WorkspaceInviteUseInput = {
export type WorkspaceMutations = {
__typename?: 'WorkspaceMutations';
addDomain: Workspace;
billing: WorkspaceBillingMutations;
create: Workspace;
delete: Scalars['Boolean']['output'];
deleteDomain: Workspace;
@ -4150,6 +4209,27 @@ export type WorkspaceMutationsUpdateRoleArgs = {
input: WorkspaceRoleUpdateInput;
};
export type WorkspacePlan = {
__typename?: 'WorkspacePlan';
name: WorkspacePlans;
status: WorkspacePlanStatuses;
};
export enum WorkspacePlanStatuses {
Canceled = 'canceled',
PaymentFailed = 'paymentFailed',
Trial = 'trial',
Valid = 'valid'
}
export enum WorkspacePlans {
Academia = 'academia',
Business = 'business',
Pro = 'pro',
Team = 'team',
Unlimited = 'unlimited'
}
export type WorkspaceProjectInviteCreateInput = {
/** Either this or userId must be filled */
email?: InputMaybe<Scalars['String']['input']>;
@ -4203,6 +4283,14 @@ export type WorkspaceRoleUpdateInput = {
workspaceId: Scalars['String']['input'];
};
export type WorkspaceSubscription = {
__typename?: 'WorkspaceSubscription';
billingInterval: BillingInterval;
createdAt: Scalars['DateTime']['output'];
currentBillingCycleEnd: Scalars['DateTime']['output'];
updatedAt: Scalars['DateTime']['output'];
};
export type WorkspaceTeamFilter = {
/** Limit team members to provided role(s) */
roles?: InputMaybe<Array<Scalars['String']['input']>>;
@ -4345,6 +4433,7 @@ export type ResolversTypes = {
AvatarUser: ResolverTypeWrapper<AvatarUser>;
BasicGitRepositoryMetadata: ResolverTypeWrapper<BasicGitRepositoryMetadata>;
BigInt: ResolverTypeWrapper<Scalars['BigInt']['output']>;
BillingInterval: BillingInterval;
BlobMetadata: ResolverTypeWrapper<BlobStorageItem>;
BlobMetadataCollection: ResolverTypeWrapper<Omit<BlobMetadataCollection, 'items'> & { items?: Maybe<Array<ResolversTypes['BlobMetadata']>> }>;
Boolean: ResolverTypeWrapper<Scalars['Boolean']['output']>;
@ -4353,6 +4442,9 @@ export type ResolversTypes = {
BranchCreateInput: BranchCreateInput;
BranchDeleteInput: BranchDeleteInput;
BranchUpdateInput: BranchUpdateInput;
CancelCheckoutSessionInput: CancelCheckoutSessionInput;
CheckoutSession: ResolverTypeWrapper<CheckoutSession>;
CheckoutSessionInput: CheckoutSessionInput;
Comment: ResolverTypeWrapper<CommentGraphQLReturn>;
CommentActivityMessage: ResolverTypeWrapper<Omit<CommentActivityMessage, 'comment'> & { comment: ResolversTypes['Comment'] }>;
CommentCollection: ResolverTypeWrapper<Omit<CommentCollection, 'items'> & { items: Array<ResolversTypes['Comment']> }>;
@ -4412,6 +4504,7 @@ export type ResolversTypes = {
Object: ResolverTypeWrapper<ObjectGraphQLReturn>;
ObjectCollection: ResolverTypeWrapper<Omit<ObjectCollection, 'objects'> & { objects: Array<ResolversTypes['Object']> }>;
ObjectCreateInput: ObjectCreateInput;
PaidWorkspacePlans: PaidWorkspacePlans;
PasswordStrengthCheckFeedback: ResolverTypeWrapper<PasswordStrengthCheckFeedback>;
PasswordStrengthCheckResults: ResolverTypeWrapper<PasswordStrengthCheckResults>;
PendingStreamCollaborator: ResolverTypeWrapper<PendingStreamCollaboratorGraphQLReturn>;
@ -4480,6 +4573,7 @@ export type ResolversTypes = {
ServerStatistics: ResolverTypeWrapper<GraphQLEmptyReturn>;
ServerStats: ResolverTypeWrapper<GraphQLEmptyReturn>;
ServerWorkspacesInfo: ResolverTypeWrapper<GraphQLEmptyReturn>;
SessionPaymentStatus: SessionPaymentStatus;
SetPrimaryUserEmailInput: SetPrimaryUserEmailInput;
SmartTextEditorValue: ResolverTypeWrapper<SmartTextEditorValueSchema>;
SortDirection: SortDirection;
@ -4537,6 +4631,7 @@ export type ResolversTypes = {
WebhookUpdateInput: WebhookUpdateInput;
Workspace: ResolverTypeWrapper<WorkspaceGraphQLReturn>;
WorkspaceBilling: ResolverTypeWrapper<WorkspaceBillingGraphQLReturn>;
WorkspaceBillingMutations: ResolverTypeWrapper<WorkspaceBillingMutationsGraphQLReturn>;
WorkspaceCollaborator: ResolverTypeWrapper<WorkspaceCollaboratorGraphQLReturn>;
WorkspaceCollaboratorCollection: ResolverTypeWrapper<Omit<WorkspaceCollaboratorCollection, 'items'> & { items: Array<ResolversTypes['WorkspaceCollaborator']> }>;
WorkspaceCollection: ResolverTypeWrapper<Omit<WorkspaceCollection, 'items'> & { items: Array<ResolversTypes['Workspace']> }>;
@ -4552,12 +4647,16 @@ export type ResolversTypes = {
WorkspaceInviteResendInput: WorkspaceInviteResendInput;
WorkspaceInviteUseInput: WorkspaceInviteUseInput;
WorkspaceMutations: ResolverTypeWrapper<WorkspaceMutationsGraphQLReturn>;
WorkspacePlan: ResolverTypeWrapper<WorkspacePlan>;
WorkspacePlanStatuses: WorkspacePlanStatuses;
WorkspacePlans: WorkspacePlans;
WorkspaceProjectInviteCreateInput: WorkspaceProjectInviteCreateInput;
WorkspaceProjectMutations: ResolverTypeWrapper<WorkspaceProjectMutationsGraphQLReturn>;
WorkspaceProjectsFilter: WorkspaceProjectsFilter;
WorkspaceRole: WorkspaceRole;
WorkspaceRoleDeleteInput: WorkspaceRoleDeleteInput;
WorkspaceRoleUpdateInput: WorkspaceRoleUpdateInput;
WorkspaceSubscription: ResolverTypeWrapper<WorkspaceSubscription>;
WorkspaceTeamFilter: WorkspaceTeamFilter;
WorkspaceUpdateInput: WorkspaceUpdateInput;
WorkspaceVersionsCount: ResolverTypeWrapper<WorkspaceVersionsCount>;
@ -4613,6 +4712,9 @@ export type ResolversParentTypes = {
BranchCreateInput: BranchCreateInput;
BranchDeleteInput: BranchDeleteInput;
BranchUpdateInput: BranchUpdateInput;
CancelCheckoutSessionInput: CancelCheckoutSessionInput;
CheckoutSession: CheckoutSession;
CheckoutSessionInput: CheckoutSessionInput;
Comment: CommentGraphQLReturn;
CommentActivityMessage: Omit<CommentActivityMessage, 'comment'> & { comment: ResolversParentTypes['Comment'] };
CommentCollection: Omit<CommentCollection, 'items'> & { items: Array<ResolversParentTypes['Comment']> };
@ -4778,6 +4880,7 @@ export type ResolversParentTypes = {
WebhookUpdateInput: WebhookUpdateInput;
Workspace: WorkspaceGraphQLReturn;
WorkspaceBilling: WorkspaceBillingGraphQLReturn;
WorkspaceBillingMutations: WorkspaceBillingMutationsGraphQLReturn;
WorkspaceCollaborator: WorkspaceCollaboratorGraphQLReturn;
WorkspaceCollaboratorCollection: Omit<WorkspaceCollaboratorCollection, 'items'> & { items: Array<ResolversParentTypes['WorkspaceCollaborator']> };
WorkspaceCollection: Omit<WorkspaceCollection, 'items'> & { items: Array<ResolversParentTypes['Workspace']> };
@ -4793,11 +4896,13 @@ export type ResolversParentTypes = {
WorkspaceInviteResendInput: WorkspaceInviteResendInput;
WorkspaceInviteUseInput: WorkspaceInviteUseInput;
WorkspaceMutations: WorkspaceMutationsGraphQLReturn;
WorkspacePlan: WorkspacePlan;
WorkspaceProjectInviteCreateInput: WorkspaceProjectInviteCreateInput;
WorkspaceProjectMutations: WorkspaceProjectMutationsGraphQLReturn;
WorkspaceProjectsFilter: WorkspaceProjectsFilter;
WorkspaceRoleDeleteInput: WorkspaceRoleDeleteInput;
WorkspaceRoleUpdateInput: WorkspaceRoleUpdateInput;
WorkspaceSubscription: WorkspaceSubscription;
WorkspaceTeamFilter: WorkspaceTeamFilter;
WorkspaceUpdateInput: WorkspaceUpdateInput;
WorkspaceVersionsCount: WorkspaceVersionsCount;
@ -5126,6 +5231,17 @@ export type BranchCollectionResolvers<ContextType = GraphQLContext, ParentType e
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type CheckoutSessionResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['CheckoutSession'] = ResolversParentTypes['CheckoutSession']> = {
billingInterval?: Resolver<ResolversTypes['BillingInterval'], ParentType, ContextType>;
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
paymentStatus?: Resolver<ResolversTypes['SessionPaymentStatus'], ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
workspacePlan?: Resolver<ResolversTypes['PaidWorkspacePlans'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type CommentResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['Comment'] = ResolversParentTypes['Comment']> = {
archived?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
author?: Resolver<ResolversTypes['LimitedUser'], ParentType, ContextType>;
@ -6152,6 +6268,7 @@ export type WebhookEventCollectionResolvers<ContextType = GraphQLContext, Parent
export type WorkspaceResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['Workspace'] = ResolversParentTypes['Workspace']> = {
billing?: Resolver<Maybe<ResolversTypes['WorkspaceBilling']>, ParentType, ContextType>;
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
customerPortalUrl?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
defaultLogoIndex?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
defaultProjectRole?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
description?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
@ -6162,9 +6279,11 @@ export type WorkspaceResolvers<ContextType = GraphQLContext, ParentType extends
invitedTeam?: Resolver<Maybe<Array<ResolversTypes['PendingWorkspaceCollaborator']>>, ParentType, ContextType, Partial<WorkspaceInvitedTeamArgs>>;
logo?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
plan?: Resolver<Maybe<ResolversTypes['WorkspacePlan']>, ParentType, ContextType>;
projects?: Resolver<ResolversTypes['ProjectCollection'], ParentType, ContextType, RequireFields<WorkspaceProjectsArgs, 'limit'>>;
role?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
slug?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
subscription?: Resolver<Maybe<ResolversTypes['WorkspaceSubscription']>, ParentType, ContextType>;
team?: Resolver<ResolversTypes['WorkspaceCollaboratorCollection'], ParentType, ContextType, RequireFields<WorkspaceTeamArgs, 'limit'>>;
updatedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -6176,6 +6295,12 @@ export type WorkspaceBillingResolvers<ContextType = GraphQLContext, ParentType e
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WorkspaceBillingMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspaceBillingMutations'] = ResolversParentTypes['WorkspaceBillingMutations']> = {
cancelCheckoutSession?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<WorkspaceBillingMutationsCancelCheckoutSessionArgs, 'input'>>;
createCheckoutSession?: Resolver<ResolversTypes['CheckoutSession'], ParentType, ContextType, RequireFields<WorkspaceBillingMutationsCreateCheckoutSessionArgs, 'input'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WorkspaceCollaboratorResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspaceCollaborator'] = ResolversParentTypes['WorkspaceCollaborator']> = {
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
projectRoles?: Resolver<Array<ResolversTypes['ProjectRole']>, ParentType, ContextType>;
@ -6238,6 +6363,7 @@ export type WorkspaceInviteMutationsResolvers<ContextType = GraphQLContext, Pare
export type WorkspaceMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspaceMutations'] = ResolversParentTypes['WorkspaceMutations']> = {
addDomain?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceMutationsAddDomainArgs, 'input'>>;
billing?: Resolver<ResolversTypes['WorkspaceBillingMutations'], ParentType, ContextType>;
create?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceMutationsCreateArgs, 'input'>>;
delete?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<WorkspaceMutationsDeleteArgs, 'workspaceId'>>;
deleteDomain?: Resolver<ResolversTypes['Workspace'], ParentType, ContextType, RequireFields<WorkspaceMutationsDeleteDomainArgs, 'input'>>;
@ -6250,12 +6376,26 @@ export type WorkspaceMutationsResolvers<ContextType = GraphQLContext, ParentType
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WorkspacePlanResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspacePlan'] = ResolversParentTypes['WorkspacePlan']> = {
name?: Resolver<ResolversTypes['WorkspacePlans'], ParentType, ContextType>;
status?: Resolver<ResolversTypes['WorkspacePlanStatuses'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WorkspaceProjectMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspaceProjectMutations'] = ResolversParentTypes['WorkspaceProjectMutations']> = {
moveToWorkspace?: Resolver<ResolversTypes['Project'], ParentType, ContextType, RequireFields<WorkspaceProjectMutationsMoveToWorkspaceArgs, 'projectId' | 'workspaceId'>>;
updateRole?: Resolver<ResolversTypes['Project'], ParentType, ContextType, RequireFields<WorkspaceProjectMutationsUpdateRoleArgs, 'input'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WorkspaceSubscriptionResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspaceSubscription'] = ResolversParentTypes['WorkspaceSubscription']> = {
billingInterval?: Resolver<ResolversTypes['BillingInterval'], ParentType, ContextType>;
createdAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
currentBillingCycleEnd?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type WorkspaceVersionsCountResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['WorkspaceVersionsCount'] = ResolversParentTypes['WorkspaceVersionsCount']> = {
current?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
max?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
@ -6297,6 +6437,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
BlobMetadataCollection?: BlobMetadataCollectionResolvers<ContextType>;
Branch?: BranchResolvers<ContextType>;
BranchCollection?: BranchCollectionResolvers<ContextType>;
CheckoutSession?: CheckoutSessionResolvers<ContextType>;
Comment?: CommentResolvers<ContextType>;
CommentActivityMessage?: CommentActivityMessageResolvers<ContextType>;
CommentCollection?: CommentCollectionResolvers<ContextType>;
@ -6393,6 +6534,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
WebhookEventCollection?: WebhookEventCollectionResolvers<ContextType>;
Workspace?: WorkspaceResolvers<ContextType>;
WorkspaceBilling?: WorkspaceBillingResolvers<ContextType>;
WorkspaceBillingMutations?: WorkspaceBillingMutationsResolvers<ContextType>;
WorkspaceCollaborator?: WorkspaceCollaboratorResolvers<ContextType>;
WorkspaceCollaboratorCollection?: WorkspaceCollaboratorCollectionResolvers<ContextType>;
WorkspaceCollection?: WorkspaceCollectionResolvers<ContextType>;
@ -6402,7 +6544,9 @@ export type Resolvers<ContextType = GraphQLContext> = {
WorkspaceDomain?: WorkspaceDomainResolvers<ContextType>;
WorkspaceInviteMutations?: WorkspaceInviteMutationsResolvers<ContextType>;
WorkspaceMutations?: WorkspaceMutationsResolvers<ContextType>;
WorkspacePlan?: WorkspacePlanResolvers<ContextType>;
WorkspaceProjectMutations?: WorkspaceProjectMutationsResolvers<ContextType>;
WorkspaceSubscription?: WorkspaceSubscriptionResolvers<ContextType>;
WorkspaceVersionsCount?: WorkspaceVersionsCountResolvers<ContextType>;
};

Просмотреть файл

@ -1,7 +1,11 @@
import { Roles, Scopes, AllScopes as BaseAllScopes } from '@speckle/shared'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
const { FF_AUTOMATE_MODULE_ENABLED, FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
const {
FF_AUTOMATE_MODULE_ENABLED,
FF_WORKSPACES_MODULE_ENABLED,
FF_GATEKEEPER_MODULE_ENABLED
} = getFeatureFlags()
const buildAllScopes = () => {
let base = BaseAllScopes
@ -33,6 +37,11 @@ const buildAllScopes = () => {
)
}
if (!FF_GATEKEEPER_MODULE_ENABLED) {
base = base.filter(
(s: string) => !([Scopes.Gatekeeper.WorkspaceBilling] as string[]).includes(s)
)
}
return base
}

Просмотреть файл

@ -426,6 +426,11 @@ export type BasicGitRepositoryMetadata = {
url: Scalars['String']['output'];
};
export enum BillingInterval {
Monthly = 'monthly',
Yearly = 'yearly'
}
export type BlobMetadata = {
__typename?: 'BlobMetadata';
createdAt: Scalars['DateTime']['output'];
@ -503,6 +508,28 @@ export type BranchUpdateInput = {
streamId: Scalars['String']['input'];
};
export type CancelCheckoutSessionInput = {
sessionId: Scalars['ID']['input'];
workspaceId: Scalars['ID']['input'];
};
export type CheckoutSession = {
__typename?: 'CheckoutSession';
billingInterval: BillingInterval;
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
paymentStatus: SessionPaymentStatus;
updatedAt: Scalars['DateTime']['output'];
url: Scalars['String']['output'];
workspacePlan: PaidWorkspacePlans;
};
export type CheckoutSessionInput = {
billingInterval: BillingInterval;
workspaceId: Scalars['ID']['input'];
workspacePlan: PaidWorkspacePlans;
};
export type Comment = {
__typename?: 'Comment';
archived: Scalars['Boolean']['output'];
@ -1712,6 +1739,12 @@ export type ObjectCreateInput = {
streamId: Scalars['String']['input'];
};
export enum PaidWorkspacePlans {
Business = 'business',
Pro = 'pro',
Team = 'team'
}
export type PasswordStrengthCheckFeedback = {
__typename?: 'PasswordStrengthCheckFeedback';
suggestions: Array<Scalars['String']['output']>;
@ -2855,6 +2888,11 @@ export type ServerWorkspacesInfo = {
workspacesEnabled: Scalars['Boolean']['output'];
};
export enum SessionPaymentStatus {
Paid = 'paid',
Unpaid = 'unpaid'
}
export type SetPrimaryUserEmailInput = {
id: Scalars['ID']['input'];
};
@ -3892,6 +3930,7 @@ export type Workspace = {
/** Billing data for Workspaces beta */
billing?: Maybe<WorkspaceBilling>;
createdAt: Scalars['DateTime']['output'];
customerPortalUrl?: Maybe<Scalars['String']['output']>;
/** Selected fallback when `logo` not set */
defaultLogoIndex: Scalars['Int']['output'];
/** The default role workspace members will receive for workspace projects. */
@ -3909,10 +3948,12 @@ export type Workspace = {
/** Logo image as base64-encoded string */
logo?: Maybe<Scalars['String']['output']>;
name: Scalars['String']['output'];
plan?: Maybe<WorkspacePlan>;
projects: ProjectCollection;
/** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */
role?: Maybe<Scalars['String']['output']>;
slug: Scalars['String']['output'];
subscription?: Maybe<WorkspaceSubscription>;
team: WorkspaceCollaboratorCollection;
updatedAt: Scalars['DateTime']['output'];
};
@ -3942,6 +3983,22 @@ export type WorkspaceBilling = {
versionsCount: WorkspaceVersionsCount;
};
export type WorkspaceBillingMutations = {
__typename?: 'WorkspaceBillingMutations';
cancelCheckoutSession: Scalars['Boolean']['output'];
createCheckoutSession: CheckoutSession;
};
export type WorkspaceBillingMutationsCancelCheckoutSessionArgs = {
input: CancelCheckoutSessionInput;
};
export type WorkspaceBillingMutationsCreateCheckoutSessionArgs = {
input: CheckoutSessionInput;
};
/** Overridden by `WorkspaceCollaboratorGraphQLReturn` */
export type WorkspaceCollaborator = {
__typename?: 'WorkspaceCollaborator';
@ -4083,6 +4140,7 @@ export type WorkspaceInviteUseInput = {
export type WorkspaceMutations = {
__typename?: 'WorkspaceMutations';
addDomain: Workspace;
billing: WorkspaceBillingMutations;
create: Workspace;
delete: Scalars['Boolean']['output'];
deleteDomain: Workspace;
@ -4134,6 +4192,27 @@ export type WorkspaceMutationsUpdateRoleArgs = {
input: WorkspaceRoleUpdateInput;
};
export type WorkspacePlan = {
__typename?: 'WorkspacePlan';
name: WorkspacePlans;
status: WorkspacePlanStatuses;
};
export enum WorkspacePlanStatuses {
Canceled = 'canceled',
PaymentFailed = 'paymentFailed',
Trial = 'trial',
Valid = 'valid'
}
export enum WorkspacePlans {
Academia = 'academia',
Business = 'business',
Pro = 'pro',
Team = 'team',
Unlimited = 'unlimited'
}
export type WorkspaceProjectInviteCreateInput = {
/** Either this or userId must be filled */
email?: InputMaybe<Scalars['String']['input']>;
@ -4187,6 +4266,14 @@ export type WorkspaceRoleUpdateInput = {
workspaceId: Scalars['String']['input'];
};
export type WorkspaceSubscription = {
__typename?: 'WorkspaceSubscription';
billingInterval: BillingInterval;
createdAt: Scalars['DateTime']['output'];
currentBillingCycleEnd: Scalars['DateTime']['output'];
updatedAt: Scalars['DateTime']['output'];
};
export type WorkspaceTeamFilter = {
/** Limit team members to provided role(s) */
roles?: InputMaybe<Array<Scalars['String']['input']>>;

Просмотреть файл

@ -2,6 +2,7 @@
import {
CreateCheckoutSession,
GetSubscriptionData,
SubscriptionData,
WorkspaceSubscription
} from '@/modules/gatekeeper/domain/billing'
import {
@ -15,6 +16,19 @@ type GetWorkspacePlanPrice = (args: {
billingInterval: WorkspacePlanBillingIntervals
}) => string
const getResultUrl = ({
frontendOrigin,
workspaceId,
workspaceSlug
}: {
frontendOrigin: string
workspaceSlug: string
workspaceId: string
}) =>
new URL(
`${frontendOrigin}/workspaces/${workspaceSlug}?workspace=${workspaceId}&settings=workspace/billing`
)
export const createCheckoutSessionFactory =
({
stripe,
@ -34,10 +48,7 @@ export const createCheckoutSessionFactory =
workspaceId
}) => {
//?settings=workspace/security&
const resultUrl = new URL(
`${frontendOrigin}/workspaces/${workspaceSlug}?workspace=${workspaceId}&settings=workspace/billing`
)
const resultUrl = getResultUrl({ frontendOrigin, workspaceId, workspaceSlug })
const price = getWorkspacePlanPrice({ billingInterval, workspacePlan })
const costLineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = [
{ price, quantity: seatCount }
@ -57,8 +68,7 @@ export const createCheckoutSessionFactory =
line_items: costLineItems,
success_url: `${resultUrl.toString()}&payment_status=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${resultUrl.toString()}&payment_status=cancelled&session_id={CHECKOUT_SESSION_ID}`
cancel_url: `${resultUrl.toString()}&payment_status=canceled&session_id={CHECKOUT_SESSION_ID}`
})
if (!session.url) throw new Error('Failed to create an active checkout session')
@ -74,6 +84,36 @@ export const createCheckoutSessionFactory =
}
}
export const createCustomerPortalUrlFactory =
({
stripe,
frontendOrigin
}: // getWorkspacePlanPrice
{
stripe: Stripe
frontendOrigin: string
// getWorkspacePlanPrice: GetWorkspacePlanPrice
}) =>
async ({
workspaceId,
workspaceSlug,
customerId
}: {
customerId: string
workspaceId: string
workspaceSlug: string
}): Promise<string> => {
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: getResultUrl({
frontendOrigin,
workspaceId,
workspaceSlug
}).toString()
})
return session.url
}
export const getSubscriptionDataFactory =
({
stripe
@ -84,33 +124,42 @@ export const getSubscriptionDataFactory =
}): GetSubscriptionData =>
async ({ subscriptionId }) => {
const stripeSubscription = await stripe.subscriptions.retrieve(subscriptionId)
return {
customerId:
typeof stripeSubscription.customer === 'string'
? stripeSubscription.customer
: stripeSubscription.customer.id,
subscriptionId,
products: stripeSubscription.items.data.map((subscriptionItem) => {
const productId =
typeof subscriptionItem.price.product === 'string'
? subscriptionItem.price.product
: subscriptionItem.price.product.id
const quantity = subscriptionItem.quantity
if (!quantity)
throw new Error(
'invalid subscription, we do not support products without quantities'
)
return {
priceId: subscriptionItem.price.id,
productId,
quantity,
subscriptionItemId: subscriptionItem.id
}
})
}
return parseSubscriptionData(stripeSubscription)
}
export const parseSubscriptionData = (
stripeSubscription: Stripe.Subscription
): SubscriptionData => {
return {
customerId:
typeof stripeSubscription.customer === 'string'
? stripeSubscription.customer
: stripeSubscription.customer.id,
subscriptionId: stripeSubscription.id,
status: stripeSubscription.status,
cancelAt: stripeSubscription.cancel_at
? new Date(stripeSubscription.cancel_at)
: null,
products: stripeSubscription.items.data.map((subscriptionItem) => {
const productId =
typeof subscriptionItem.price.product === 'string'
? subscriptionItem.price.product
: subscriptionItem.price.product.id
const quantity = subscriptionItem.quantity
if (!quantity)
throw new Error(
'invalid subscription, we do not support products without quantities'
)
return {
priceId: subscriptionItem.price.id,
productId,
quantity,
subscriptionItemId: subscriptionItem.id
}
})
}
}
// this should be a reconcile subscriptions, we keep an accurate state in the DB
// on each change, we're reconciling that state to stripe
export const reconcileWorkspaceSubscriptionFactory =

Просмотреть файл

@ -13,9 +13,10 @@ export type PaidWorkspacePlanStatuses =
| UnpaidWorkspacePlanStatuses
// | 'paymentNeeded' // unsure if this is needed
| 'paymentFailed'
| 'cancelled'
| 'cancelationScheduled'
| 'canceled'
export type TrialWorkspacePlanStatuses = 'trial'
export type TrialWorkspacePlanStatuses = 'trial' | 'expired'
type BaseWorkspacePlan = {
workspaceId: string
@ -108,10 +109,20 @@ export type WorkspaceSubscription = {
billingInterval: WorkspacePlanBillingIntervals
subscriptionData: SubscriptionData
}
export const subscriptionData = z.object({
subscriptionId: z.string().min(1),
customerId: z.string().min(1),
cancelAt: z.date().nullable(),
status: z.union([
z.literal('incomplete'),
z.literal('incomplete_expired'),
z.literal('trialing'),
z.literal('active'),
z.literal('past_due'),
z.literal('canceled'),
z.literal('unpaid'),
z.literal('paused')
]),
products: z
.object({
// we're going to use the productId to match with our
@ -126,10 +137,18 @@ export const subscriptionData = z.object({
// this abstracts the stripe sub data
export type SubscriptionData = z.infer<typeof subscriptionData>
export type SaveWorkspaceSubscription = (args: {
export type UpsertWorkspaceSubscription = (args: {
workspaceSubscription: WorkspaceSubscription
}) => Promise<void>
export type GetWorkspaceSubscription = (args: {
workspaceId: string
}) => Promise<WorkspaceSubscription | null>
export type GetWorkspaceSubscriptionBySubscriptionId = (args: {
subscriptionId: string
}) => Promise<WorkspaceSubscription | null>
export type GetSubscriptionData = (args: {
subscriptionId: string
}) => Promise<SubscriptionData>

Просмотреть файл

@ -6,6 +6,12 @@ export class WorkspacePlanNotFoundError extends BaseError {
static statusCode = 500
}
export class WorkspacePlanMismatchError extends BaseError {
static defaultMessage = 'Workspace plan is not matching the expected state'
static code = 'WORKSPACE_PLAN_MISMATCH'
static statusCode = 500
}
export class WorkspaceCheckoutSessionInProgressError extends BaseError {
static defaultMessage = 'Workspace already has a checkout session in progress'
static code = 'WORKSPACE_CHECKOUT_SESSION_IN_PROGRESS_ERROR'
@ -23,3 +29,9 @@ export class CheckoutSessionNotFoundError extends BaseError {
static code = 'CHECKOUT_SESSION_NOT_FOUND'
static statusCode = 404
}
export class WorkspaceSubscriptionNotFoundError extends BaseError {
static defaultMessage = 'Workspace subscription not found'
static code = 'WORKSPACE_SUBSCRIPTION_NOT_FOUND'
static statusCode = 404
}

Просмотреть файл

@ -1,6 +1,27 @@
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { getFeatureFlags, getFrontendOrigin } from '@/modules/shared/helpers/envHelper'
import { Resolvers } from '@/modules/core/graph/generated/graphql'
import { pricingTable } from '@/modules/gatekeeper/domain/workspacePricing'
import { authorizeResolver } from '@/modules/shared'
import { Roles } from '@speckle/shared'
import {
countWorkspaceRoleWithOptionalProjectRoleFactory,
getWorkspaceFactory
} from '@/modules/workspaces/repositories/workspaces'
import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace'
import { db } from '@/db/knex'
import {
createCheckoutSessionFactory,
createCustomerPortalUrlFactory
} from '@/modules/gatekeeper/clients/stripe'
import { getWorkspacePlanPrice, getStripeClient } from '@/modules/gatekeeper/stripe'
import { startCheckoutSessionFactory } from '@/modules/gatekeeper/services/checkout'
import {
deleteCheckoutSessionFactory,
getWorkspaceCheckoutSessionFactory,
getWorkspacePlanFactory,
getWorkspaceSubscriptionFactory,
saveCheckoutSessionFactory
} from '@/modules/gatekeeper/repositories/billing'
const { FF_GATEKEEPER_MODULE_ENABLED } = getFeatureFlags()
@ -10,6 +31,98 @@ export = FF_GATEKEEPER_MODULE_ENABLED
workspacePricingPlans: async () => {
return pricingTable
}
},
Workspace: {
plan: async (parent) => {
return await getWorkspacePlanFactory({ db })({ workspaceId: parent.id })
},
subscription: async (parent, _, ctx) => {
const workspaceId = parent.id
await authorizeResolver(
ctx.userId,
workspaceId,
Roles.Workspace.Admin,
ctx.resourceAccessRules
)
return await getWorkspaceSubscriptionFactory({ db })({ workspaceId })
},
customerPortalUrl: async (parent, _, ctx) => {
const workspaceId = parent.id
await authorizeResolver(
ctx.userId,
workspaceId,
Roles.Workspace.Admin,
ctx.resourceAccessRules
)
const workspaceSubscription = await getWorkspaceSubscriptionFactory({ db })({
workspaceId
})
if (!workspaceSubscription) return null
const workspace = await getWorkspaceFactory({ db })({ workspaceId })
if (!workspace)
throw new Error('This cannot be, if there is a sub, there is a workspace')
return await createCustomerPortalUrlFactory({
stripe: getStripeClient(),
frontendOrigin: getFrontendOrigin()
})({
workspaceId: workspaceSubscription.workspaceId,
workspaceSlug: workspace.slug,
customerId: workspaceSubscription.subscriptionData.customerId
})
}
},
WorkspaceMutations: () => ({}),
WorkspaceBillingMutations: {
cancelCheckoutSession: async (parent, args, ctx) => {
const { workspaceId, sessionId } = args.input
await authorizeResolver(
ctx.userId,
workspaceId,
Roles.Workspace.Admin,
ctx.resourceAccessRules
)
await deleteCheckoutSessionFactory({ db })({ checkoutSessionId: sessionId })
return true
},
createCheckoutSession: async (parent, args, ctx) => {
const { workspaceId, workspacePlan, billingInterval } = args.input
const workspace = await getWorkspaceFactory({ db })({ workspaceId })
if (!workspace) throw new WorkspaceNotFoundError()
await authorizeResolver(
ctx.userId,
workspaceId,
Roles.Workspace.Admin,
ctx.resourceAccessRules
)
const createCheckoutSession = createCheckoutSessionFactory({
stripe: getStripeClient(),
frontendOrigin: getFrontendOrigin(),
getWorkspacePlanPrice
})
const countRole = countWorkspaceRoleWithOptionalProjectRoleFactory({ db })
const session = await startCheckoutSessionFactory({
getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory({ db }),
getWorkspacePlan: getWorkspacePlanFactory({ db }),
countRole,
createCheckoutSession,
saveCheckoutSession: saveCheckoutSessionFactory({ db }),
deleteCheckoutSession: deleteCheckoutSessionFactory({ db })
})({
workspacePlan,
workspaceId,
workspaceSlug: workspace.slug,
billingInterval
})
return session
}
}
} as Resolvers)
: {}

Просмотреть файл

@ -0,0 +1,3 @@
import { MutationsObjectGraphQLReturn } from '@/modules/core/helpers/graphTypes'
export type WorkspaceBillingMutationsGraphQLReturn = MutationsObjectGraphQLReturn

Просмотреть файл

@ -3,12 +3,21 @@ import { SpeckleModule } from '@/modules/shared/helpers/typeHelper'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { validateModuleLicense } from '@/modules/gatekeeper/services/validateLicense'
import { getBillingRouter } from '@/modules/gatekeeper/rest/billing'
import { registerOrUpdateScopeFactory } from '@/modules/shared/repositories/scopes'
import { db } from '@/db/knex'
import { gatekeeperScopes } from '@/modules/gatekeeper/scopes'
const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } =
getFeatureFlags()
const initScopes = async () => {
const registerFunc = registerOrUpdateScopeFactory({ db })
await Promise.all(gatekeeperScopes.map((scope) => registerFunc({ scope })))
}
const gatekeeperModule: SpeckleModule = {
async init(app, isInitial) {
await initScopes()
if (!FF_GATEKEEPER_MODULE_ENABLED) return
const isLicenseValid = await validateModuleLicense({

Просмотреть файл

@ -5,12 +5,14 @@ import {
SaveCheckoutSession,
UpdateCheckoutSessionStatus,
UpsertWorkspacePlan,
SaveWorkspaceSubscription,
UpsertWorkspaceSubscription,
WorkspaceSubscription,
WorkspacePlan,
UpsertPaidWorkspacePlan,
DeleteCheckoutSession,
GetWorkspaceCheckoutSession
GetWorkspaceCheckoutSession,
GetWorkspaceSubscription,
GetWorkspaceSubscriptionBySubscriptionId
} from '@/modules/gatekeeper/domain/billing'
import { Knex } from 'knex'
@ -94,8 +96,34 @@ export const updateCheckoutSessionStatusFactory =
.update({ paymentStatus, updatedAt: new Date() })
}
export const saveWorkspaceSubscriptionFactory =
({ db }: { db: Knex }): SaveWorkspaceSubscription =>
export const upsertWorkspaceSubscriptionFactory =
({ db }: { db: Knex }): UpsertWorkspaceSubscription =>
async ({ workspaceSubscription }) => {
await tables.workspaceSubscriptions(db).insert(workspaceSubscription)
await tables
.workspaceSubscriptions(db)
.insert(workspaceSubscription)
.onConflict('workspaceId')
.merge()
}
export const getWorkspaceSubscriptionFactory =
({ db }: { db: Knex }): GetWorkspaceSubscription =>
async ({ workspaceId }) => {
const subscription = await tables
.workspaceSubscriptions(db)
.select()
.where({ workspaceId })
.first()
return subscription || null
}
export const getWorkspaceSubscriptionBySubscriptionIdFactory =
({ db }: { db: Knex }): GetWorkspaceSubscriptionBySubscriptionId =>
async ({ subscriptionId }) => {
const subscription = await tables
.workspaceSubscriptions(db)
.select()
.whereRaw(`"subscriptionData" ->> 'subscriptionId' = ?`, [subscriptionId])
.first()
return subscription ?? null
}

Просмотреть файл

@ -1,19 +1,15 @@
import { Router } from 'express'
import { validateRequest } from 'zod-express'
import { z } from 'zod'
import { authorizeResolver } from '@/modules/shared'
import { ensureError, Roles } from '@speckle/shared'
import { authorizeResolver, validateScopes } from '@/modules/shared'
import { ensureError, Roles, Scopes } from '@speckle/shared'
import { Stripe } from 'stripe'
import {
getFrontendOrigin,
getStringFromEnv,
getStripeApiKey,
getStripeEndpointSigningKey
} from '@/modules/shared/helpers/envHelper'
import {
WorkspacePlanBillingIntervals,
paidWorkspacePlans,
WorkspacePricingPlans,
workspacePlanBillingIntervals
} from '@/modules/gatekeeper/domain/workspacePricing'
import {
@ -28,57 +24,30 @@ import {
import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace'
import {
createCheckoutSessionFactory,
getSubscriptionDataFactory
createCustomerPortalUrlFactory,
getSubscriptionDataFactory,
parseSubscriptionData
} from '@/modules/gatekeeper/clients/stripe'
import {
deleteCheckoutSessionFactory,
getCheckoutSessionFactory,
getWorkspaceCheckoutSessionFactory,
getWorkspacePlanFactory,
getWorkspaceSubscriptionFactory,
saveCheckoutSessionFactory,
saveWorkspaceSubscriptionFactory,
upsertWorkspaceSubscriptionFactory,
updateCheckoutSessionStatusFactory,
upsertPaidWorkspacePlanFactory
upsertPaidWorkspacePlanFactory,
getWorkspaceSubscriptionBySubscriptionIdFactory
} from '@/modules/gatekeeper/repositories/billing'
import { GetWorkspacePlanPrice } from '@/modules/gatekeeper/domain/billing'
import { WorkspaceAlreadyPaidError } from '@/modules/gatekeeper/errors/billing'
import { withTransaction } from '@/modules/shared/helpers/dbHelper'
import { getStripeClient, getWorkspacePlanPrice } from '@/modules/gatekeeper/stripe'
import { handleSubscriptionUpdateFactory } from '@/modules/gatekeeper/services/subscriptions'
export const getBillingRouter = (): Router => {
const workspacePlanPrices: Record<
WorkspacePricingPlans,
Record<WorkspacePlanBillingIntervals, string> & { productId: string }
> = {
guest: {
productId: getStringFromEnv('WORKSPACE_GUEST_SEAT_STRIPE_PRODUCT_ID'),
monthly: getStringFromEnv('WORKSPACE_MONTHLY_GUEST_SEAT_STRIPE_PRICE_ID'),
yearly: getStringFromEnv('WORKSPACE_YEARLY_GUEST_SEAT_STRIPE_PRICE_ID')
},
team: {
productId: getStringFromEnv('WORKSPACE_TEAM_SEAT_STRIPE_PRODUCT_ID'),
monthly: getStringFromEnv('WORKSPACE_MONTHLY_TEAM_SEAT_STRIPE_PRICE_ID'),
yearly: getStringFromEnv('WORKSPACE_YEARLY_TEAM_SEAT_STRIPE_PRICE_ID')
},
pro: {
productId: getStringFromEnv('WORKSPACE_PRO_SEAT_STRIPE_PRODUCT_ID'),
monthly: getStringFromEnv('WORKSPACE_MONTHLY_PRO_SEAT_STRIPE_PRICE_ID'),
yearly: getStringFromEnv('WORKSPACE_YEARLY_PRO_SEAT_STRIPE_PRICE_ID')
},
business: {
productId: getStringFromEnv('WORKSPACE_BUSINESS_SEAT_STRIPE_PRODUCT_ID'),
monthly: getStringFromEnv('WORKSPACE_MONTHLY_BUSINESS_SEAT_STRIPE_PRICE_ID'),
yearly: getStringFromEnv('WORKSPACE_YEARLY_BUSINESS_SEAT_STRIPE_PRICE_ID')
}
}
const getWorkspacePlanPrice: GetWorkspacePlanPrice = ({
workspacePlan,
billingInterval
}) => workspacePlanPrices[workspacePlan][billingInterval]
const router = Router()
const stripe = new Stripe(getStripeApiKey(), { typescript: true })
// this prob needs to be turned into a GQL resolver for better frontend integration for errors
router.get(
'/api/v1/billing/workspaces/:workspaceId/checkout-session/:workspacePlan/:billingInterval',
@ -95,6 +64,7 @@ export const getBillingRouter = (): Router => {
if (!workspace) throw new WorkspaceNotFoundError()
await validateScopes(req.context.scopes, Scopes.Gatekeeper.WorkspaceBilling)
await authorizeResolver(
req.context.userId,
workspaceId,
@ -103,7 +73,7 @@ export const getBillingRouter = (): Router => {
)
const createCheckoutSession = createCheckoutSessionFactory({
stripe,
stripe: getStripeClient(),
frontendOrigin: getFrontendOrigin(),
getWorkspacePlanPrice
})
@ -115,13 +85,49 @@ export const getBillingRouter = (): Router => {
getWorkspacePlan: getWorkspacePlanFactory({ db }),
countRole,
createCheckoutSession,
saveCheckoutSession: saveCheckoutSessionFactory({ db })
saveCheckoutSession: saveCheckoutSessionFactory({ db }),
deleteCheckoutSession: deleteCheckoutSessionFactory({ db })
})({ workspacePlan, workspaceId, workspaceSlug: workspace.slug, billingInterval })
req.res?.redirect(session.url)
}
)
router.get(
'/api/v1/billing/workspaces/:workspaceId/customer-portal',
validateRequest({
params: z.object({
workspaceId: z.string().min(1)
})
}),
async (req) => {
const { workspaceId } = req.params
await authorizeResolver(
req.context.userId,
workspaceId,
Roles.Workspace.Admin,
req.context.resourceAccessRules
)
const workspaceSubscription = await getWorkspaceSubscriptionFactory({ db })({
workspaceId
})
if (!workspaceSubscription) return null
const workspace = await getWorkspaceFactory({ db })({ workspaceId })
if (!workspace)
throw new Error('This cannot be, if there is a sub, there is a workspace')
const stripe = getStripeClient()
const url = await createCustomerPortalUrlFactory({
stripe,
frontendOrigin: getFrontendOrigin()
})({
workspaceId: workspaceSubscription.workspaceId,
workspaceSlug: workspace.slug,
customerId: workspaceSubscription.subscriptionData.customerId
})
return req.res?.redirect(url)
}
)
router.post('/api/v1/billing/webhooks', async (req, res) => {
const endpointSecret = getStripeEndpointSigningKey()
const sig = req.headers['stripe-signature']
@ -130,6 +136,7 @@ export const getBillingRouter = (): Router => {
return
}
const stripe = getStripeClient()
let event: Stripe.Event
try {
@ -146,7 +153,10 @@ export const getBillingRouter = (): Router => {
switch (event.type) {
case 'checkout.session.async_payment_failed':
// TODO: need to alert the user and delete the session ?
// if payment fails, we delete the failed session
await deleteCheckoutSessionFactory({ db })({
checkoutSessionId: event.data.object.id
})
break
case 'checkout.session.async_payment_succeeded':
case 'checkout.session.completed':
@ -155,52 +165,65 @@ export const getBillingRouter = (): Router => {
if (!session.subscription)
return res.status(400).send('We only support subscription type checkouts')
if (session.payment_status === 'paid') {
// If the workspace is already on a paid plan, we made a bo bo.
// existing subs should be updated via the api, not pushed through the checkout sess again
// the start checkout endpoint should guard this!
// get checkout session from the DB, if not found CONTACT SUPPORT!!!
// if the session is already paid, means, we've already settled this checkout, and this is a webhook recall
// set checkout state to paid
// go ahead and provision the plan
// store customer id and subscription Id associated to the workspace plan
switch (session.payment_status) {
case 'no_payment_required':
// we do not need to support this status
break
case 'paid':
// If the workspace is already on a paid plan, we made a bo bo.
// existing subs should be updated via the api, not pushed through the checkout sess again
// the start checkout endpoint should guard this!
// get checkout session from the DB, if not found CONTACT SUPPORT!!!
// if the session is already paid, means, we've already settled this checkout, and this is a webhook recall
// set checkout state to paid
// go ahead and provision the plan
// store customer id and subscription Id associated to the workspace plan
const subscriptionId =
typeof session.subscription === 'string'
? session.subscription
: session.subscription.id
const subscriptionId =
typeof session.subscription === 'string'
? session.subscription
: session.subscription.id
// this must use a transaction
// this must use a transaction
const trx = await db.transaction()
const trx = await db.transaction()
const completeCheckout = completeCheckoutSessionFactory({
getCheckoutSession: getCheckoutSessionFactory({ db: trx }),
updateCheckoutSessionStatus: updateCheckoutSessionStatusFactory({
db: trx
}),
upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db: trx }),
saveWorkspaceSubscription: saveWorkspaceSubscriptionFactory({ db: trx }),
getSubscriptionData: getSubscriptionDataFactory({
stripe
})
})
try {
await withTransaction(
completeCheckout({
sessionId: session.id,
subscriptionId
const completeCheckout = completeCheckoutSessionFactory({
getCheckoutSession: getCheckoutSessionFactory({ db: trx }),
updateCheckoutSessionStatus: updateCheckoutSessionStatusFactory({
db: trx
}),
trx
)
} catch (err) {
if (err instanceof WorkspaceAlreadyPaidError) {
// ignore the request, this is prob a replay from stripe
} else {
throw err
upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db: trx }),
upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({
db: trx
}),
getSubscriptionData: getSubscriptionDataFactory({
stripe
})
})
try {
await withTransaction(
completeCheckout({
sessionId: session.id,
subscriptionId
}),
trx
)
} catch (err) {
if (err instanceof WorkspaceAlreadyPaidError) {
// ignore the request, this is prob a replay from stripe
} else {
throw err
}
}
}
break
case 'unpaid':
// if payment fails, we delete the failed session
await deleteCheckoutSessionFactory({ db })({
checkoutSessionId: event.data.object.id
})
}
break
@ -211,6 +234,18 @@ export const getBillingRouter = (): Router => {
})
break
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
await handleSubscriptionUpdateFactory({
getWorkspacePlan: getWorkspacePlanFactory({ db }),
upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db }),
getWorkspaceSubscriptionBySubscriptionId:
getWorkspaceSubscriptionBySubscriptionIdFactory({ db }),
upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db })
})({ subscriptionData: parseSubscriptionData(event.data.object) })
break
default:
break
}
@ -218,9 +253,5 @@ export const getBillingRouter = (): Router => {
res.status(200).send('ok')
})
// prob needed when the checkout is cancelled
router.delete(
'/api/v1/billing/workspaces/:workspaceSlug/checkout-session/:workspacePlan'
)
return router
}

Просмотреть файл

@ -0,0 +1,10 @@
import { TokenScopeData } from '@/modules/shared/domain/rolesAndScopes/types'
import { Scopes } from '@speckle/shared'
export const gatekeeperScopes: TokenScopeData[] = [
{
name: Scopes.Gatekeeper.WorkspaceBilling,
description: 'Scope for managing workspace billing',
public: false
}
]

Просмотреть файл

@ -5,10 +5,11 @@ import {
GetWorkspacePlan,
SaveCheckoutSession,
UpdateCheckoutSessionStatus,
SaveWorkspaceSubscription,
UpsertWorkspaceSubscription,
UpsertPaidWorkspacePlan,
GetSubscriptionData,
GetWorkspaceCheckoutSession
GetWorkspaceCheckoutSession,
DeleteCheckoutSession
} from '@/modules/gatekeeper/domain/billing'
import {
PaidWorkspacePlans,
@ -25,12 +26,14 @@ import { Roles, throwUncoveredError } from '@speckle/shared'
export const startCheckoutSessionFactory =
({
getWorkspaceCheckoutSession,
deleteCheckoutSession,
getWorkspacePlan,
countRole,
createCheckoutSession,
saveCheckoutSession
}: {
getWorkspaceCheckoutSession: GetWorkspaceCheckoutSession
deleteCheckoutSession: DeleteCheckoutSession
getWorkspacePlan: GetWorkspacePlan
countRole: CountWorkspaceRoleWithOptionalProjectRole
createCheckoutSession: CreateCheckoutSession
@ -50,19 +53,51 @@ export const startCheckoutSessionFactory =
// get workspace plan, if we're already on a paid plan, do not allow checkout
// paid plans should use a subscription modification
const existingWorkspacePlan = await getWorkspacePlan({ workspaceId })
// it will technically not be possible to not have
if (existingWorkspacePlan) {
// maybe we can just ignore the plan not existing, cause we're putting it on a plan post checkout
switch (existingWorkspacePlan.status) {
// valid and paymentFailed, but not cancelled status is not something we need a checkout for
// valid and paymentFailed, but not canceled status is not something we need a checkout for
// we already have their credit card info
case 'valid':
case 'paymentFailed':
case 'cancelationScheduled':
throw new WorkspaceAlreadyPaidError()
case 'cancelled':
// maybe, we can reactivate cancelled plans via the sub in stripe, but this is fine too
case 'canceled':
const existingCheckoutSession = await getWorkspaceCheckoutSession({
workspaceId
})
if (existingCheckoutSession)
await deleteCheckoutSession({
checkoutSessionId: existingCheckoutSession?.id
})
break
// maybe, we can reactivate canceled plans via the sub in stripe, but this is fine too
// it will create a new customer and a new sub though, the reactivation would use the existing customer
case 'trial':
case 'expired':
// if there is already a checkout session for the workspace, stop, someone else is maybe trying to pay for the workspace
const workspaceCheckoutSession = await getWorkspaceCheckoutSession({
workspaceId
})
if (workspaceCheckoutSession) {
if (workspaceCheckoutSession.paymentStatus === 'paid')
// this is should not be possible, but its better to be checking it here, than double charging the customer
throw new WorkspaceAlreadyPaidError()
if (
new Date().getTime() - workspaceCheckoutSession.createdAt.getTime() >
10 * 60 * 1000
) {
await deleteCheckoutSession({
checkoutSessionId: workspaceCheckoutSession.id
})
} else {
throw new WorkspaceCheckoutSessionInProgressError()
}
}
// lets go ahead and pay
break
default:
@ -70,10 +105,6 @@ export const startCheckoutSessionFactory =
}
}
// if there is already a checkout session for the workspace, stop, someone else is maybe trying to pay for the workspace
const workspaceCheckoutSession = await getWorkspaceCheckoutSession({ workspaceId })
if (workspaceCheckoutSession) throw new WorkspaceCheckoutSessionInProgressError()
const [adminCount, memberCount, guestCount] = await Promise.all([
countRole({ workspaceId, workspaceRole: Roles.Workspace.Admin }),
countRole({ workspaceId, workspaceRole: Roles.Workspace.Member }),
@ -98,13 +129,13 @@ export const completeCheckoutSessionFactory =
({
getCheckoutSession,
updateCheckoutSessionStatus,
saveWorkspaceSubscription,
upsertWorkspaceSubscription,
upsertPaidWorkspacePlan,
getSubscriptionData
}: {
getCheckoutSession: GetCheckoutSession
updateCheckoutSessionStatus: UpdateCheckoutSessionStatus
saveWorkspaceSubscription: SaveWorkspaceSubscription
upsertWorkspaceSubscription: UpsertWorkspaceSubscription
upsertPaidWorkspacePlan: UpsertPaidWorkspacePlan
getSubscriptionData: GetSubscriptionData
}) =>
@ -166,7 +197,7 @@ export const completeCheckoutSessionFactory =
subscriptionData
}
await saveWorkspaceSubscription({
await upsertWorkspaceSubscription({
workspaceSubscription
})
}

Просмотреть файл

@ -0,0 +1,80 @@
import {
GetWorkspacePlan,
GetWorkspaceSubscriptionBySubscriptionId,
PaidWorkspacePlanStatuses,
SubscriptionData,
UpsertPaidWorkspacePlan,
UpsertWorkspaceSubscription
} from '@/modules/gatekeeper/domain/billing'
import {
WorkspacePlanMismatchError,
WorkspacePlanNotFoundError,
WorkspaceSubscriptionNotFoundError
} from '@/modules/gatekeeper/errors/billing'
import { throwUncoveredError } from '@speckle/shared'
export const handleSubscriptionUpdateFactory =
({
upsertPaidWorkspacePlan,
getWorkspacePlan,
getWorkspaceSubscriptionBySubscriptionId,
upsertWorkspaceSubscription
}: {
getWorkspacePlan: GetWorkspacePlan
upsertPaidWorkspacePlan: UpsertPaidWorkspacePlan
getWorkspaceSubscriptionBySubscriptionId: GetWorkspaceSubscriptionBySubscriptionId
upsertWorkspaceSubscription: UpsertWorkspaceSubscription
}) =>
async ({ subscriptionData }: { subscriptionData: SubscriptionData }) => {
// we're only handling marking the sub scheduled for cancelation right now
const subscription = await getWorkspaceSubscriptionBySubscriptionId({
subscriptionId: subscriptionData.subscriptionId
})
if (!subscription) throw new WorkspaceSubscriptionNotFoundError()
const workspacePlan = await getWorkspacePlan({
workspaceId: subscription.workspaceId
})
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
let status: PaidWorkspacePlanStatuses | undefined = undefined
if (
subscriptionData.status === 'active' &&
subscriptionData.cancelAt &&
subscriptionData.cancelAt > new Date()
) {
status = 'cancelationScheduled'
} else if (
subscriptionData.status === 'active' &&
subscriptionData.cancelAt === null
) {
status = 'valid'
} else if (subscriptionData.status === 'past_due') {
status = 'paymentFailed'
} else if (subscriptionData.status === 'canceled') {
status = 'canceled'
}
if (status) {
switch (workspacePlan.name) {
case 'team':
case 'pro':
case 'business':
break
case 'unlimited':
case 'academia':
throw new WorkspacePlanMismatchError()
default:
throwUncoveredError(workspacePlan)
}
await upsertPaidWorkspacePlan({
workspacePlan: { ...workspacePlan, status }
})
// if there is a status in the sub, we recognize, we need to update our state
await upsertWorkspaceSubscription({
workspaceSubscription: { ...subscription, subscriptionData }
})
}
}

Просмотреть файл

@ -0,0 +1,45 @@
import { GetWorkspacePlanPrice } from '@/modules/gatekeeper/domain/billing'
import {
WorkspacePlanBillingIntervals,
WorkspacePricingPlans
} from '@/modules/gatekeeper/domain/workspacePricing'
import { getStringFromEnv, getStripeApiKey } from '@/modules/shared/helpers/envHelper'
import { Stripe } from 'stripe'
let stripeClient: Stripe | undefined = undefined
export const getStripeClient = () => {
if (!stripeClient) stripeClient = new Stripe(getStripeApiKey(), { typescript: true })
return stripeClient
}
export const workspacePlanPrices = (): Record<
WorkspacePricingPlans,
Record<WorkspacePlanBillingIntervals, string> & { productId: string }
> => ({
guest: {
productId: getStringFromEnv('WORKSPACE_GUEST_SEAT_STRIPE_PRODUCT_ID'),
monthly: getStringFromEnv('WORKSPACE_MONTHLY_GUEST_SEAT_STRIPE_PRICE_ID'),
yearly: getStringFromEnv('WORKSPACE_YEARLY_GUEST_SEAT_STRIPE_PRICE_ID')
},
team: {
productId: getStringFromEnv('WORKSPACE_TEAM_SEAT_STRIPE_PRODUCT_ID'),
monthly: getStringFromEnv('WORKSPACE_MONTHLY_TEAM_SEAT_STRIPE_PRICE_ID'),
yearly: getStringFromEnv('WORKSPACE_YEARLY_TEAM_SEAT_STRIPE_PRICE_ID')
},
pro: {
productId: getStringFromEnv('WORKSPACE_PRO_SEAT_STRIPE_PRODUCT_ID'),
monthly: getStringFromEnv('WORKSPACE_MONTHLY_PRO_SEAT_STRIPE_PRICE_ID'),
yearly: getStringFromEnv('WORKSPACE_YEARLY_PRO_SEAT_STRIPE_PRICE_ID')
},
business: {
productId: getStringFromEnv('WORKSPACE_BUSINESS_SEAT_STRIPE_PRODUCT_ID'),
monthly: getStringFromEnv('WORKSPACE_MONTHLY_BUSINESS_SEAT_STRIPE_PRICE_ID'),
yearly: getStringFromEnv('WORKSPACE_YEARLY_BUSINESS_SEAT_STRIPE_PRICE_ID')
}
})
export const getWorkspacePlanPrice: GetWorkspacePlanPrice = ({
workspacePlan,
billingInterval
}) => workspacePlanPrices()[workspacePlan][billingInterval]

Просмотреть файл

@ -1,13 +1,16 @@
import db from '@/db/knex'
import { WorkspaceSubscription } from '@/modules/gatekeeper/domain/billing'
import {
deleteCheckoutSessionFactory,
getCheckoutSessionFactory,
getWorkspaceCheckoutSessionFactory,
getWorkspacePlanFactory,
saveCheckoutSessionFactory,
saveWorkspaceSubscriptionFactory,
upsertWorkspaceSubscriptionFactory,
updateCheckoutSessionStatusFactory,
upsertPaidWorkspacePlanFactory
upsertPaidWorkspacePlanFactory,
getWorkspaceSubscriptionFactory,
getWorkspaceSubscriptionBySubscriptionIdFactory
} from '@/modules/gatekeeper/repositories/billing'
import { upsertWorkspaceFactory } from '@/modules/workspaces/repositories/workspaces'
import { createAndStoreTestWorkspaceFactory } from '@/test/speckle-helpers/workspaces'
@ -25,7 +28,10 @@ const deleteCheckoutSession = deleteCheckoutSessionFactory({ db })
const getCheckoutSession = getCheckoutSessionFactory({ db })
const getWorkspaceCheckoutSession = getWorkspaceCheckoutSessionFactory({ db })
const updateCheckoutSessionStatus = updateCheckoutSessionStatusFactory({ db })
const saveWorkspaceSubscription = saveWorkspaceSubscriptionFactory({ db })
const upsertWorkspaceSubscription = upsertWorkspaceSubscriptionFactory({ db })
const getWorkspaceSubscription = getWorkspaceSubscriptionFactory({ db })
const getWorkspaceSubscriptionBySubscriptionId =
getWorkspaceSubscriptionBySubscriptionIdFactory({ db })
describe('billing repositories @gatekeeper', () => {
describe('workspacePlans', () => {
@ -194,24 +200,87 @@ describe('billing repositories @gatekeeper', () => {
})
})
describe('workspaceSubscriptions', () => {
describe('saveWorkspaceSubscription creates a function, that', () => {
it('saves the subscription', async () => {
describe('upsertWorkspaceSubscription creates a function, that', () => {
it('saves and updates the subscription', async () => {
const workspace = await createAndStoreTestWorkspace()
const workspaceId = workspace.id
await saveWorkspaceSubscription({
workspaceSubscription: {
billingInterval: 'monthly',
createdAt: new Date(),
updatedAt: new Date(),
currentBillingCycleEnd: new Date(),
subscriptionData: {
customerId: cryptoRandomString({ length: 10 }),
products: [],
subscriptionId: cryptoRandomString({ length: 10 })
},
workspaceId
}
const workspaceSubscription: WorkspaceSubscription = {
billingInterval: 'monthly' as const,
createdAt: new Date(),
updatedAt: new Date(),
currentBillingCycleEnd: new Date(),
subscriptionData: {
customerId: cryptoRandomString({ length: 10 }),
status: 'active' as const,
cancelAt: null,
products: [
{
priceId: cryptoRandomString({ length: 10 }),
quantity: 10,
productId: cryptoRandomString({ length: 10 }),
subscriptionItemId: cryptoRandomString({ length: 10 })
}
],
subscriptionId: cryptoRandomString({ length: 10 })
},
workspaceId
}
await upsertWorkspaceSubscription({ workspaceSubscription })
let storedSubscription = await getWorkspaceSubscription({ workspaceId })
expect(storedSubscription).deep.equal(workspaceSubscription)
workspaceSubscription.billingInterval = 'yearly'
workspaceSubscription.subscriptionData.products[0].quantity = 3
await upsertWorkspaceSubscription({ workspaceSubscription })
storedSubscription = await getWorkspaceSubscription({ workspaceId })
expect(storedSubscription).deep.equal(workspaceSubscription)
})
})
describe('getWorkspaceSubscriptionFactory creates a function, that', () => {
it('returns null if the subscription is not found', async () => {
const sub = await getWorkspaceSubscription({
workspaceId: cryptoRandomString({ length: 10 })
})
expect(sub).to.be.null
})
})
describe('getWorkspaceSubscriptionBySubscriptionIdFactory creates a function, that', () => {
it('returns null if the subscription is not found', async () => {
const sub = await getWorkspaceSubscriptionBySubscriptionId({
subscriptionId: cryptoRandomString({ length: 10 })
})
expect(sub).to.be.null
})
it('returns the sub', async () => {
const workspace = await createAndStoreTestWorkspace()
const workspaceId = workspace.id
const workspaceSubscription: WorkspaceSubscription = {
billingInterval: 'monthly' as const,
createdAt: new Date(),
updatedAt: new Date(),
currentBillingCycleEnd: new Date(),
subscriptionData: {
customerId: cryptoRandomString({ length: 10 }),
status: 'active' as const,
cancelAt: null,
products: [
{
priceId: cryptoRandomString({ length: 10 }),
quantity: 10,
productId: cryptoRandomString({ length: 10 }),
subscriptionItemId: cryptoRandomString({ length: 10 })
}
],
subscriptionId: cryptoRandomString({ length: 10 })
},
workspaceId
}
await upsertWorkspaceSubscription({ workspaceSubscription })
const storedSubscription = await getWorkspaceSubscriptionBySubscriptionId({
subscriptionId: workspaceSubscription.subscriptionData.subscriptionId
})
expect(storedSubscription).deep.equal(workspaceSubscription)
})
})
})

Просмотреть файл

@ -43,6 +43,9 @@ describe('checkout @gatekeeper', () => {
},
saveCheckoutSession: () => {
expect.fail()
},
deleteCheckoutSession: () => {
expect.fail()
}
})({
workspaceId,
@ -71,6 +74,9 @@ describe('checkout @gatekeeper', () => {
createCheckoutSession: () => {
expect.fail()
},
deleteCheckoutSession: () => {
expect.fail()
},
saveCheckoutSession: () => {
expect.fail()
}
@ -83,6 +89,49 @@ describe('checkout @gatekeeper', () => {
)
expect(err.message).to.be.equal(new WorkspaceAlreadyPaidError().message)
})
it('does not allow checkout for a workspace, that already has a recent checkout session', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
name: 'team',
status: 'trial',
workspaceId
}),
getWorkspaceCheckoutSession: async () => ({
billingInterval: 'monthly',
id: cryptoRandomString({ length: 10 }),
paymentStatus: 'unpaid',
url: '',
workspaceId,
workspacePlan: 'business',
createdAt: new Date(),
updatedAt: new Date()
}),
countRole: () => {
expect.fail()
},
createCheckoutSession: () => {
expect.fail()
},
deleteCheckoutSession: () => {
expect.fail()
},
saveCheckoutSession: () => {
expect.fail()
}
})({
workspaceId,
billingInterval: 'monthly',
workspacePlan: 'business',
workspaceSlug: cryptoRandomString({ length: 10 })
})
)
expect(err.message).to.be.equal(
new WorkspaceCheckoutSessionInProgressError().message
)
})
it('does not allow checkout for a workspace, that already has a checkout session', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(() =>
@ -108,6 +157,10 @@ describe('checkout @gatekeeper', () => {
createCheckoutSession: () => {
expect.fail()
},
deleteCheckoutSession: () => {
expect.fail()
},
saveCheckoutSession: () => {
expect.fail()
}
@ -141,6 +194,9 @@ describe('checkout @gatekeeper', () => {
getWorkspacePlan: async () => null,
getWorkspaceCheckoutSession: async () => null,
countRole: async () => 1,
deleteCheckoutSession: () => {
expect.fail()
},
createCheckoutSession: async () => checkoutSession,
saveCheckoutSession: async ({ checkoutSession }) => {
storedCheckoutSession = checkoutSession
@ -154,7 +210,7 @@ describe('checkout @gatekeeper', () => {
expect(checkoutSession).deep.equal(storedCheckoutSession)
expect(checkoutSession).deep.equal(createdCheckoutSession)
})
it('creates and stores a checkout for TRIAL and CANCELLED workspaces', async () => {
it('creates and stores a checkout for workspaces without a plan', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const workspacePlan: PaidWorkspacePlans = 'pro'
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
@ -173,6 +229,9 @@ describe('checkout @gatekeeper', () => {
getWorkspacePlan: async () => null,
getWorkspaceCheckoutSession: async () => null,
countRole: async () => 1,
deleteCheckoutSession: () => {
expect.fail()
},
createCheckoutSession: async () => checkoutSession,
saveCheckoutSession: async ({ checkoutSession }) => {
storedCheckoutSession = checkoutSession
@ -186,6 +245,222 @@ describe('checkout @gatekeeper', () => {
expect(checkoutSession).deep.equal(storedCheckoutSession)
expect(checkoutSession).deep.equal(createdCheckoutSession)
})
it('creates and stores a checkout for TRIAL workspaces', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const workspacePlan: PaidWorkspacePlans = 'pro'
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
const checkoutSession: CheckoutSession = {
id: cryptoRandomString({ length: 10 }),
workspaceId,
workspacePlan,
url: 'https://example.com',
billingInterval,
paymentStatus: 'unpaid',
createdAt: new Date(),
updatedAt: new Date()
}
let storedCheckoutSession: CheckoutSession | undefined = undefined
const createdCheckoutSession = await startCheckoutSessionFactory({
getWorkspacePlan: async () => ({ workspaceId, name: 'team', status: 'trial' }),
getWorkspaceCheckoutSession: async () => null,
countRole: async () => 1,
deleteCheckoutSession: () => {
expect.fail()
},
createCheckoutSession: async () => checkoutSession,
saveCheckoutSession: async ({ checkoutSession }) => {
storedCheckoutSession = checkoutSession
}
})({
workspaceId,
billingInterval,
workspacePlan,
workspaceSlug: cryptoRandomString({ length: 10 })
})
expect(checkoutSession).deep.equal(storedCheckoutSession)
expect(checkoutSession).deep.equal(createdCheckoutSession)
})
it('creates and stores a checkout for TRIAL workspaces even if it has an old unpaid checkout session', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const workspacePlan: PaidWorkspacePlans = 'pro'
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
const checkoutSession: CheckoutSession = {
id: cryptoRandomString({ length: 10 }),
workspaceId,
workspacePlan,
url: 'https://example.com',
billingInterval,
paymentStatus: 'unpaid',
createdAt: new Date(),
updatedAt: new Date()
}
let existingCheckoutSession: CheckoutSession | undefined = {
billingInterval,
id: cryptoRandomString({ length: 10 }),
createdAt: new Date(1990, 1, 12),
updatedAt: new Date(1990, 1, 12),
paymentStatus: 'unpaid',
url: 'https://example.com',
workspaceId,
workspacePlan
}
let storedCheckoutSession: CheckoutSession | undefined = undefined
const createdCheckoutSession = await startCheckoutSessionFactory({
getWorkspacePlan: async () => ({ workspaceId, name: 'team', status: 'trial' }),
getWorkspaceCheckoutSession: async () => existingCheckoutSession!,
countRole: async () => 1,
deleteCheckoutSession: async () => {
existingCheckoutSession = undefined
},
createCheckoutSession: async () => checkoutSession,
saveCheckoutSession: async ({ checkoutSession }) => {
storedCheckoutSession = checkoutSession
}
})({
workspaceId,
billingInterval,
workspacePlan,
workspaceSlug: cryptoRandomString({ length: 10 })
})
expect(existingCheckoutSession).to.be.undefined
expect(checkoutSession).deep.equal(storedCheckoutSession)
expect(checkoutSession).deep.equal(createdCheckoutSession)
})
it('does not allow checkout for TRIAL workspaces if there is a paid checkout session', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const workspacePlan: PaidWorkspacePlans = 'pro'
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
let existingCheckoutSession: CheckoutSession | undefined = {
billingInterval,
id: cryptoRandomString({ length: 10 }),
createdAt: new Date(1990, 1, 12),
updatedAt: new Date(1990, 1, 12),
paymentStatus: 'paid',
url: 'https://example.com',
workspaceId,
workspacePlan
}
const err = await expectToThrow(async () => {
await startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
workspaceId,
name: 'team',
status: 'trial'
}),
getWorkspaceCheckoutSession: async () => existingCheckoutSession!,
countRole: async () => 1,
deleteCheckoutSession: async () => {
existingCheckoutSession = undefined
},
createCheckoutSession: async () => {
expect.fail()
},
saveCheckoutSession: async () => {}
})({
workspaceId,
billingInterval,
workspacePlan,
workspaceSlug: cryptoRandomString({ length: 10 })
})
})
expect(err.message).to.equal(new WorkspaceAlreadyPaidError().message)
})
it('does not allow checkout for TRIAL workspaces if there is a paid checkout session', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const workspacePlan: PaidWorkspacePlans = 'pro'
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
let existingCheckoutSession: CheckoutSession | undefined = {
billingInterval,
id: cryptoRandomString({ length: 10 }),
createdAt: new Date(),
updatedAt: new Date(),
paymentStatus: 'unpaid',
url: 'https://example.com',
workspaceId,
workspacePlan
}
const err = await expectToThrow(async () => {
await startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
workspaceId,
name: 'team',
status: 'trial'
}),
getWorkspaceCheckoutSession: async () => existingCheckoutSession!,
countRole: async () => 1,
deleteCheckoutSession: async () => {
existingCheckoutSession = undefined
},
createCheckoutSession: async () => {
expect.fail()
},
saveCheckoutSession: async () => {}
})({
workspaceId,
billingInterval,
workspacePlan,
workspaceSlug: cryptoRandomString({ length: 10 })
})
})
expect(err.message).to.equal(
new WorkspaceCheckoutSessionInProgressError().message
)
})
it('creates and stores a checkout for CANCELED workspaces', async () => {
const workspaceId = cryptoRandomString({ length: 10 })
const workspacePlan: PaidWorkspacePlans = 'pro'
const billingInterval: WorkspacePlanBillingIntervals = 'monthly'
const checkoutSession: CheckoutSession = {
id: cryptoRandomString({ length: 10 }),
workspaceId,
workspacePlan,
url: 'https://example.com',
billingInterval,
paymentStatus: 'unpaid',
createdAt: new Date(),
updatedAt: new Date()
}
let existingCheckoutSession: CheckoutSession | undefined = {
billingInterval: 'monthly',
id: cryptoRandomString({ length: 10 }),
paymentStatus: 'paid',
url: '',
workspaceId,
workspacePlan: 'business',
createdAt: new Date(),
updatedAt: new Date()
}
let storedCheckoutSession: CheckoutSession | undefined = undefined
const createdCheckoutSession = await startCheckoutSessionFactory({
getWorkspacePlan: async () => ({
name: 'pro',
workspaceId,
status: 'canceled'
}),
getWorkspaceCheckoutSession: async () => existingCheckoutSession!,
countRole: async () => 1,
deleteCheckoutSession: async () => {
existingCheckoutSession = undefined
},
createCheckoutSession: async () => checkoutSession,
saveCheckoutSession: async ({ checkoutSession }) => {
storedCheckoutSession = checkoutSession
}
})({
workspaceId,
billingInterval,
workspacePlan,
workspaceSlug: cryptoRandomString({ length: 10 })
})
expect(existingCheckoutSession).to.be.undefined
expect(checkoutSession).deep.equal(storedCheckoutSession)
expect(checkoutSession).deep.equal(createdCheckoutSession)
})
})
describe('completeCheckoutSessionFactory creates a function, that', () => {
it('throws a CheckoutSessionNotFound if the checkoutSession is null', async () => {
@ -204,7 +479,7 @@ describe('checkout @gatekeeper', () => {
getSubscriptionData: async () => {
expect.fail()
},
saveWorkspaceSubscription: async () => {
upsertWorkspaceSubscription: async () => {
expect.fail()
}
})({ sessionId, subscriptionId })
@ -236,7 +511,7 @@ describe('checkout @gatekeeper', () => {
getSubscriptionData: async () => {
expect.fail()
},
saveWorkspaceSubscription: async () => {
upsertWorkspaceSubscription: async () => {
expect.fail()
}
})({ sessionId, subscriptionId })
@ -272,7 +547,9 @@ describe('checkout @gatekeeper', () => {
quantity: 10,
subscriptionItemId: cryptoRandomString({ length: 10 })
}
]
],
status: 'active',
cancelAt: null
}
let storedWorkspaceSubscriptionData: WorkspaceSubscription | undefined =
@ -287,7 +564,7 @@ describe('checkout @gatekeeper', () => {
storedWorkspacePlan = workspacePlan
},
getSubscriptionData: async () => subscriptionData,
saveWorkspaceSubscription: async ({ workspaceSubscription }) => {
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
storedWorkspaceSubscriptionData = workspaceSubscription
}
})({ sessionId, subscriptionId })

Просмотреть файл

@ -0,0 +1,262 @@
import {
SubscriptionData,
WorkspacePlan,
WorkspaceSubscription
} from '@/modules/gatekeeper/domain/billing'
import {
WorkspacePlanMismatchError,
WorkspacePlanNotFoundError,
WorkspaceSubscriptionNotFoundError
} from '@/modules/gatekeeper/errors/billing'
import { handleSubscriptionUpdateFactory } from '@/modules/gatekeeper/services/subscriptions'
import { expectToThrow } from '@/test/assertionHelper'
import { expect } from 'chai'
import cryptoRandomString from 'crypto-random-string'
import { merge } from 'lodash'
const createTestSubscriptionData = (
overrides: Partial<SubscriptionData> = {}
): SubscriptionData => {
const defaultValues: SubscriptionData = {
cancelAt: null,
customerId: cryptoRandomString({ length: 10 }),
products: [
{
priceId: cryptoRandomString({ length: 10 }),
productId: cryptoRandomString({ length: 10 }),
quantity: 3,
subscriptionItemId: cryptoRandomString({ length: 10 })
}
],
status: 'active',
subscriptionId: cryptoRandomString({ length: 10 })
}
return merge(defaultValues, overrides)
}
describe('subscriptions @gatekeeper', () => {
describe('handleSubscriptionUpdateFactory creates a function, that', () => {
it('throws if subscription is not found', async () => {
const subscriptionData = createTestSubscriptionData()
const err = await expectToThrow(async () => {
await handleSubscriptionUpdateFactory({
getWorkspaceSubscriptionBySubscriptionId: async () => null,
getWorkspacePlan: async () => {
expect.fail()
},
upsertWorkspaceSubscription: async () => {
expect.fail()
},
upsertPaidWorkspacePlan: async () => {
expect.fail()
}
})({ subscriptionData })
})
expect(err.message).to.equal(new WorkspaceSubscriptionNotFoundError().message)
})
it('throws if workspacePlan is not found', async () => {
const subscriptionData = createTestSubscriptionData()
const err = await expectToThrow(async () => {
await handleSubscriptionUpdateFactory({
getWorkspaceSubscriptionBySubscriptionId: async () => ({
subscriptionData,
billingInterval: 'monthly',
createdAt: new Date(),
updatedAt: new Date(),
currentBillingCycleEnd: new Date(),
workspaceId: cryptoRandomString({ length: 10 })
}),
getWorkspacePlan: async () => null,
upsertWorkspaceSubscription: async () => {
expect.fail()
},
upsertPaidWorkspacePlan: async () => {
expect.fail()
}
})({ subscriptionData })
})
expect(err.message).to.equal(new WorkspacePlanNotFoundError().message)
})
;(['unlimited', 'academia'] as const).forEach((name) =>
it(`throws for non paid workspace plan: ${name}`, async () => {
const subscriptionData = createTestSubscriptionData()
const workspaceId = cryptoRandomString({ length: 10 })
const err = await expectToThrow(async () => {
await handleSubscriptionUpdateFactory({
getWorkspaceSubscriptionBySubscriptionId: async () => ({
subscriptionData,
billingInterval: 'monthly',
createdAt: new Date(),
updatedAt: new Date(),
currentBillingCycleEnd: new Date(),
workspaceId
}),
getWorkspacePlan: async () => ({ name, workspaceId, status: 'valid' }),
upsertWorkspaceSubscription: async () => {
expect.fail()
},
upsertPaidWorkspacePlan: async () => {
expect.fail()
}
})({ subscriptionData })
})
expect(err.message).to.equal(new WorkspacePlanMismatchError().message)
})
)
it('sets the state to cancelationScheduled', async () => {
const subscriptionData = createTestSubscriptionData({
status: 'active',
cancelAt: new Date(2099, 12, 31)
})
const workspaceId = cryptoRandomString({ length: 10 })
const workspaceSubscription = {
subscriptionData,
billingInterval: 'monthly' as const,
createdAt: new Date(),
updatedAt: new Date(),
currentBillingCycleEnd: new Date(),
workspaceId
}
let updatedSubscription: WorkspaceSubscription | undefined = undefined
let updatedPlan: WorkspacePlan | undefined = undefined
await handleSubscriptionUpdateFactory({
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
getWorkspacePlan: async () => ({ name: 'team', workspaceId, status: 'trial' }),
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
updatedSubscription = workspaceSubscription
},
upsertPaidWorkspacePlan: async ({ workspacePlan }) => {
updatedPlan = workspacePlan
}
})({ subscriptionData })
expect(updatedPlan!.status).to.be.equal('cancelationScheduled')
expect(updatedSubscription).deep.equal(workspaceSubscription)
})
it('sets the state to valid', async () => {
const subscriptionData = createTestSubscriptionData({
status: 'active',
cancelAt: null
})
const workspaceId = cryptoRandomString({ length: 10 })
const workspaceSubscription = {
subscriptionData,
billingInterval: 'monthly' as const,
createdAt: new Date(),
updatedAt: new Date(),
currentBillingCycleEnd: new Date(),
workspaceId
}
let updatedSubscription: WorkspaceSubscription | undefined = undefined
let updatedPlan: WorkspacePlan | undefined = undefined
await handleSubscriptionUpdateFactory({
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
getWorkspacePlan: async () => ({ name: 'team', workspaceId, status: 'trial' }),
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
updatedSubscription = workspaceSubscription
},
upsertPaidWorkspacePlan: async ({ workspacePlan }) => {
updatedPlan = workspacePlan
}
})({ subscriptionData })
expect(updatedPlan!.status).to.be.equal('valid')
expect(updatedSubscription).deep.equal(workspaceSubscription)
})
it('sets the state to paymentFailed', async () => {
const subscriptionData = createTestSubscriptionData({
status: 'past_due'
})
const workspaceId = cryptoRandomString({ length: 10 })
const workspaceSubscription = {
subscriptionData,
billingInterval: 'monthly' as const,
createdAt: new Date(),
updatedAt: new Date(),
currentBillingCycleEnd: new Date(),
workspaceId
}
let updatedSubscription: WorkspaceSubscription | undefined = undefined
let updatedPlan: WorkspacePlan | undefined = undefined
await handleSubscriptionUpdateFactory({
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
getWorkspacePlan: async () => ({ name: 'team', workspaceId, status: 'trial' }),
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
updatedSubscription = workspaceSubscription
},
upsertPaidWorkspacePlan: async ({ workspacePlan }) => {
updatedPlan = workspacePlan
}
})({ subscriptionData })
expect(updatedPlan!.status).to.be.equal('paymentFailed')
expect(updatedSubscription).deep.equal(workspaceSubscription)
})
it('sets the state to canceled', async () => {
const subscriptionData = createTestSubscriptionData({
status: 'canceled'
})
const workspaceId = cryptoRandomString({ length: 10 })
const workspaceSubscription = {
subscriptionData,
billingInterval: 'monthly' as const,
createdAt: new Date(),
updatedAt: new Date(),
currentBillingCycleEnd: new Date(),
workspaceId
}
let updatedSubscription: WorkspaceSubscription | undefined = undefined
let updatedPlan: WorkspacePlan | undefined = undefined
await handleSubscriptionUpdateFactory({
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
getWorkspacePlan: async () => ({ name: 'team', workspaceId, status: 'trial' }),
upsertWorkspaceSubscription: async ({ workspaceSubscription }) => {
updatedSubscription = workspaceSubscription
},
upsertPaidWorkspacePlan: async ({ workspacePlan }) => {
updatedPlan = workspacePlan
}
})({ subscriptionData })
expect(updatedPlan!.status).to.be.equal('canceled')
expect(updatedSubscription).deep.equal(workspaceSubscription)
})
;(
['incomplete', 'incomplete_expired', 'trialing', 'unpaid', 'paused'] as const
).forEach((status) => {
it(`does not update the plan or the subscription in case of an unhandled status: ${status}`, async () => {
const subscriptionData = createTestSubscriptionData({
status
})
const workspaceId = cryptoRandomString({ length: 10 })
const workspaceSubscription = {
subscriptionData,
billingInterval: 'monthly' as const,
createdAt: new Date(),
updatedAt: new Date(),
currentBillingCycleEnd: new Date(),
workspaceId
}
await handleSubscriptionUpdateFactory({
getWorkspaceSubscriptionBySubscriptionId: async () => workspaceSubscription,
getWorkspacePlan: async () => ({
name: 'team',
workspaceId,
status: 'trial'
}),
upsertWorkspaceSubscription: async () => {
expect.fail()
},
upsertPaidWorkspacePlan: async () => {
expect.fail()
}
})({ subscriptionData })
})
})
})
})

Просмотреть файл

@ -427,6 +427,11 @@ export type BasicGitRepositoryMetadata = {
url: Scalars['String']['output'];
};
export enum BillingInterval {
Monthly = 'monthly',
Yearly = 'yearly'
}
export type BlobMetadata = {
__typename?: 'BlobMetadata';
createdAt: Scalars['DateTime']['output'];
@ -504,6 +509,28 @@ export type BranchUpdateInput = {
streamId: Scalars['String']['input'];
};
export type CancelCheckoutSessionInput = {
sessionId: Scalars['ID']['input'];
workspaceId: Scalars['ID']['input'];
};
export type CheckoutSession = {
__typename?: 'CheckoutSession';
billingInterval: BillingInterval;
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
paymentStatus: SessionPaymentStatus;
updatedAt: Scalars['DateTime']['output'];
url: Scalars['String']['output'];
workspacePlan: PaidWorkspacePlans;
};
export type CheckoutSessionInput = {
billingInterval: BillingInterval;
workspaceId: Scalars['ID']['input'];
workspacePlan: PaidWorkspacePlans;
};
export type Comment = {
__typename?: 'Comment';
archived: Scalars['Boolean']['output'];
@ -1713,6 +1740,12 @@ export type ObjectCreateInput = {
streamId: Scalars['String']['input'];
};
export enum PaidWorkspacePlans {
Business = 'business',
Pro = 'pro',
Team = 'team'
}
export type PasswordStrengthCheckFeedback = {
__typename?: 'PasswordStrengthCheckFeedback';
suggestions: Array<Scalars['String']['output']>;
@ -2856,6 +2889,11 @@ export type ServerWorkspacesInfo = {
workspacesEnabled: Scalars['Boolean']['output'];
};
export enum SessionPaymentStatus {
Paid = 'paid',
Unpaid = 'unpaid'
}
export type SetPrimaryUserEmailInput = {
id: Scalars['ID']['input'];
};
@ -3893,6 +3931,7 @@ export type Workspace = {
/** Billing data for Workspaces beta */
billing?: Maybe<WorkspaceBilling>;
createdAt: Scalars['DateTime']['output'];
customerPortalUrl?: Maybe<Scalars['String']['output']>;
/** Selected fallback when `logo` not set */
defaultLogoIndex: Scalars['Int']['output'];
/** The default role workspace members will receive for workspace projects. */
@ -3910,10 +3949,12 @@ export type Workspace = {
/** Logo image as base64-encoded string */
logo?: Maybe<Scalars['String']['output']>;
name: Scalars['String']['output'];
plan?: Maybe<WorkspacePlan>;
projects: ProjectCollection;
/** Active user's role for this workspace. `null` if request is not authenticated, or the workspace is not explicitly shared with you. */
role?: Maybe<Scalars['String']['output']>;
slug: Scalars['String']['output'];
subscription?: Maybe<WorkspaceSubscription>;
team: WorkspaceCollaboratorCollection;
updatedAt: Scalars['DateTime']['output'];
};
@ -3943,6 +3984,22 @@ export type WorkspaceBilling = {
versionsCount: WorkspaceVersionsCount;
};
export type WorkspaceBillingMutations = {
__typename?: 'WorkspaceBillingMutations';
cancelCheckoutSession: Scalars['Boolean']['output'];
createCheckoutSession: CheckoutSession;
};
export type WorkspaceBillingMutationsCancelCheckoutSessionArgs = {
input: CancelCheckoutSessionInput;
};
export type WorkspaceBillingMutationsCreateCheckoutSessionArgs = {
input: CheckoutSessionInput;
};
/** Overridden by `WorkspaceCollaboratorGraphQLReturn` */
export type WorkspaceCollaborator = {
__typename?: 'WorkspaceCollaborator';
@ -4084,6 +4141,7 @@ export type WorkspaceInviteUseInput = {
export type WorkspaceMutations = {
__typename?: 'WorkspaceMutations';
addDomain: Workspace;
billing: WorkspaceBillingMutations;
create: Workspace;
delete: Scalars['Boolean']['output'];
deleteDomain: Workspace;
@ -4135,6 +4193,27 @@ export type WorkspaceMutationsUpdateRoleArgs = {
input: WorkspaceRoleUpdateInput;
};
export type WorkspacePlan = {
__typename?: 'WorkspacePlan';
name: WorkspacePlans;
status: WorkspacePlanStatuses;
};
export enum WorkspacePlanStatuses {
Canceled = 'canceled',
PaymentFailed = 'paymentFailed',
Trial = 'trial',
Valid = 'valid'
}
export enum WorkspacePlans {
Academia = 'academia',
Business = 'business',
Pro = 'pro',
Team = 'team',
Unlimited = 'unlimited'
}
export type WorkspaceProjectInviteCreateInput = {
/** Either this or userId must be filled */
email?: InputMaybe<Scalars['String']['input']>;
@ -4188,6 +4267,14 @@ export type WorkspaceRoleUpdateInput = {
workspaceId: Scalars['String']['input'];
};
export type WorkspaceSubscription = {
__typename?: 'WorkspaceSubscription';
billingInterval: BillingInterval;
createdAt: Scalars['DateTime']['output'];
currentBillingCycleEnd: Scalars['DateTime']['output'];
updatedAt: Scalars['DateTime']['output'];
};
export type WorkspaceTeamFilter = {
/** Limit team members to provided role(s) */
roles?: InputMaybe<Array<Scalars['String']['input']>>;

Просмотреть файл

@ -12,7 +12,7 @@
"main": "./dist/commonjs/index.js",
"types": "./dist/commonjs/index.d.ts",
"scripts": {
"build": "echo \"Building shared...\" && NODE_ENV=production tshy && echo \"Done building shared!\"",
"build": "NODE_ENV=production tshy",
"dev": "tshy --watch",
"prepack": "yarn build",
"lint:eslint": "eslint .",

Просмотреть файл

@ -123,6 +123,9 @@ export const Scopes = Object.freeze(<const>{
Read: 'workspace:read',
Update: 'workspace:update',
Delete: 'workspace:delete'
},
Gatekeeper: {
WorkspaceBilling: 'workspace:billing'
}
})
@ -138,6 +141,8 @@ export type AutomateFunctionScopes =
(typeof Scopes)['AutomateFunctions'][keyof (typeof Scopes)['AutomateFunctions']]
export type WorkspaceScopes =
(typeof Scopes)['Workspaces'][keyof (typeof Scopes)['Workspaces']]
export type GatekeeperScopes =
(typeof Scopes)['Gatekeeper'][keyof (typeof Scopes)['Gatekeeper']]
export type AvailableScopes =
| StreamScopes