From 35f2d7a00403d720791a5d4c83f094ccaeed1857 Mon Sep 17 00:00:00 2001 From: Ana Sofia Marin Alexandre <61988046+anamarn@users.noreply.github.com> Date: Fri, 22 Nov 2024 11:32:48 -0300 Subject: [PATCH] Add entitlement table and sso stripe feature (#8608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **TLDR** Added Billing Entitlement table, based on stripe customer.ActiveEntitlements webhook event. In this table it has a key value pair with each key being the stripe feature lookup key and the value a boolean. We use this table in order to see if SSO or other feaures are enabled by workspace. **In order to test: twenty-server** Billing: - Set IS_BILLING_ENABLED to true - Add your BILLING_STRIPE_SECRET and BILLING_STRIPE_API_KEY - Add your BILLING_STRIPE_BASE_PLAN_PRODUCT_ID (use the one in testMode > Base Plan) Auth: - Set AUTH_SSO_ENABLED to true - Set your ACCESS_TOKEN_SECRET, LOGIN_TOKEN_SECRET, REFRESH_TOKEN_SECRET and FILE_TOKEN_SECRET - Set IS_SSO_ENABLED feature flag to true Stripe Webhook: - Authenticate with your account in the stripe CLI - Run the command: stripe listen --forward-to http://localhost:3000/billing/webhooks Migration: - npx nx typeorm -- migration:run -d src/database/typeorm/core/core.datasource.ts **In order to test: twenty site** - Buy a subscription (you can use the card 4242...42 with expiration date later in the future) - Go to SSO and create an OICD subscription - Change the value in the entitlement table in order to put it in false - An error should occur saying that the current workspace has no entitlement **Considerations** The data from the Entitlement table is updated based on the stripe webhook responses, and we use the customerActiveEntitlemet response to update the info on the table, however this event doesnt have the metadata containing the workspaceId. Because we cannot control at wich order the webhook send events, we force a server error if the entitlements are updated before the BillingSubscription. Stripe resends the event based on a exponential backoff (for more info see https://docs.stripe.com/webhooks#retries ) because we are in test mode Stripe retries three times over a few hours. So if the BillingEntitlement is not updated it is completely normal and it will be updated when stripe resends the event. --------- Co-authored-by: FĂ©lix Malfait --- package.json | 2 +- ...732098580545-addBillingEntitlementTable.ts | 25 ++++++ .../src/database/typeorm/typeorm.service.ts | 3 +- .../billing/billing.controller.ts | 25 +++++- .../core-modules/billing/billing.exception.ts | 14 +++ .../core-modules/billing/billing.module.ts | 2 + .../core-modules/billing/billing.resolver.ts | 3 +- .../billing/dto/checkout-session.input.ts | 2 +- .../billing/dto/product-price.entity.ts | 3 +- .../core-modules/billing/dto/product.input.ts | 2 +- .../entities/billing-entitlement.entity.ts | 56 ++++++++++++ .../entities/billing-subscription.entity.ts | 20 +---- .../billing-available-product.enum.ts} | 0 .../enums/billing-entitlement-key.enum.ts | 3 + .../billing-subscription-interval.enum.ts | 6 ++ .../enums/billing-subscription-status.enum.ts | 10 +++ .../enums/billing-webhook-events.enum.ts | 7 ++ .../billing-portal.workspace-service.ts | 7 -- .../services/billing-subscription.service.ts | 86 ++++++------------- .../services/billing-webhook.service.ts | 54 ++++++++++-- .../billing/services/billing.service.ts | 21 ++++- .../billing/stripe/stripe.service.ts | 7 +- .../core-modules/sso/services/sso.service.ts | 22 +++-- .../src/engine/core-modules/sso/sso.module.ts | 2 + .../workspace/workspace.entity.ts | 12 ++- yarn.lock | 10 +-- 26 files changed, 287 insertions(+), 117 deletions(-) create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/1732098580545-addBillingEntitlementTable.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/entities/billing-entitlement.entity.ts rename packages/twenty-server/src/engine/core-modules/billing/{interfaces/available-product.interface.ts => enums/billing-available-product.enum.ts} (100%) create mode 100644 packages/twenty-server/src/engine/core-modules/billing/enums/billing-entitlement-key.enum.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/enums/billing-subscription-interval.enum.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/enums/billing-subscription-status.enum.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/enums/billing-webhook-events.enum.ts diff --git a/package.json b/package.json index c9c7f6f1ef..c12755c138 100644 --- a/package.json +++ b/package.json @@ -179,7 +179,7 @@ "semver": "^7.5.4", "sharp": "^0.32.1", "slash": "^5.1.0", - "stripe": "^14.17.0", + "stripe": "^17.3.1", "ts-key-enum": "^2.0.12", "tslib": "^2.3.0", "tsup": "^8.2.4", diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/1732098580545-addBillingEntitlementTable.ts b/packages/twenty-server/src/database/typeorm/core/migrations/1732098580545-addBillingEntitlementTable.ts new file mode 100644 index 0000000000..8100f8cdf0 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/1732098580545-addBillingEntitlementTable.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBillingEntitlementTable1732098580545 + implements MigrationInterface +{ + name = 'AddBillingEntitlementTable1732098580545'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "core"."billingEntitlement" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "key" text NOT NULL, "workspaceId" uuid NOT NULL, "stripeCustomerId" character varying NOT NULL, "value" boolean NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "IndexOnFeatureKeyAndWorkspaceIdUnique" UNIQUE ("key", "workspaceId"), CONSTRAINT "PK_4e6ed788c3ca0bf6610d5022576" PRIMARY KEY ("id"))`, + ); + + await queryRunner.query( + `ALTER TABLE "core"."billingEntitlement" ADD CONSTRAINT "FK_599121a93d8177b5d713b941982" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."billingEntitlement" DROP CONSTRAINT "FK_599121a93d8177b5d713b941982"`, + ); + + await queryRunner.query(`DROP TABLE "core"."billingEntitlement"`); + } +} diff --git a/packages/twenty-server/src/database/typeorm/typeorm.service.ts b/packages/twenty-server/src/database/typeorm/typeorm.service.ts index 70138d055c..2f69306092 100644 --- a/packages/twenty-server/src/database/typeorm/typeorm.service.ts +++ b/packages/twenty-server/src/database/typeorm/typeorm.service.ts @@ -3,6 +3,7 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity'; import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; @@ -14,7 +15,6 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; - @Injectable() export class TypeORMService implements OnModuleInit, OnModuleDestroy { private mainDataSource: DataSource; @@ -36,6 +36,7 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy { FeatureFlagEntity, BillingSubscription, BillingSubscriptionItem, + BillingEntitlement, PostgresCredentials, WorkspaceSSOIdentityProvider, ], diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts index d38dfd1d57..9cac8ec106 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.controller.ts @@ -10,19 +10,22 @@ import { import { Response } from 'express'; -import { WebhookEvent } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service'; +import { + BillingException, + BillingExceptionCode, +} from 'src/engine/core-modules/billing/billing.exception'; +import { WebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { BillingWebhookService } from 'src/engine/core-modules/billing/services/billing-webhook.service'; import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; - @Controller('billing') export class BillingController { protected readonly logger = new Logger(BillingController.name); constructor( private readonly stripeService: StripeService, - private readonly billingSubscriptionService: BillingSubscriptionService, private readonly billingWehbookService: BillingWebhookService, + private readonly billingSubscriptionService: BillingSubscriptionService, ) {} @Post('/webhooks') @@ -63,6 +66,22 @@ export class BillingController { event.data, ); } + if ( + event.type === WebhookEvent.CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED + ) { + try { + await this.billingWehbookService.processCustomerActiveEntitlement( + event.data, + ); + } catch (error) { + if ( + error instanceof BillingException && + error.code === BillingExceptionCode.BILLING_CUSTOMER_NOT_FOUND + ) { + res.status(404).end(); + } + } + } res.status(200).end(); } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts new file mode 100644 index 0000000000..10e34c755e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.exception.ts @@ -0,0 +1,14 @@ +/* @license Enterprise */ + +import { CustomException } from 'src/utils/custom-exception'; + +export class BillingException extends CustomException { + code: BillingExceptionCode; + constructor(message: string, code: BillingExceptionCode) { + super(message, code); + } +} + +export enum BillingExceptionCode { + BILLING_CUSTOMER_NOT_FOUND = 'BILLING_CUSTOMER_NOT_FOUND', +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts index ff33a896ea..2d8acfff49 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { BillingController } from 'src/engine/core-modules/billing/billing.controller'; import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver'; +import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity'; import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { BillingWorkspaceMemberListener } from 'src/engine/core-modules/billing/listeners/billing-workspace-member.listener'; @@ -24,6 +25,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; [ BillingSubscription, BillingSubscriptionItem, + BillingEntitlement, Workspace, UserWorkspace, FeatureFlagEntity, diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts index 949615dd66..c7076bdc2d 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts @@ -1,14 +1,13 @@ import { UseGuards } from '@nestjs/common'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; -import { AvailableProduct } from 'src/engine/core-modules/billing/interfaces/available-product.interface'; - import { BillingSessionInput } from 'src/engine/core-modules/billing/dto/billing-session.input'; import { CheckoutSessionInput } from 'src/engine/core-modules/billing/dto/checkout-session.input'; import { ProductPricesEntity } from 'src/engine/core-modules/billing/dto/product-prices.entity'; import { ProductInput } from 'src/engine/core-modules/billing/dto/product.input'; import { SessionEntity } from 'src/engine/core-modules/billing/dto/session.entity'; import { UpdateBillingEntity } from 'src/engine/core-modules/billing/dto/update-billing.entity'; +import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum'; import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; diff --git a/packages/twenty-server/src/engine/core-modules/billing/dto/checkout-session.input.ts b/packages/twenty-server/src/engine/core-modules/billing/dto/checkout-session.input.ts index 48738b85f8..9371eee8f2 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/dto/checkout-session.input.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/dto/checkout-session.input.ts @@ -3,7 +3,7 @@ import { ArgsType, Field } from '@nestjs/graphql'; import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; import Stripe from 'stripe'; -import { SubscriptionInterval } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; +import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; @ArgsType() export class CheckoutSessionInput { diff --git a/packages/twenty-server/src/engine/core-modules/billing/dto/product-price.entity.ts b/packages/twenty-server/src/engine/core-modules/billing/dto/product-price.entity.ts index d8c2d6d434..011d880b2a 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/dto/product-price.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/dto/product-price.entity.ts @@ -2,8 +2,7 @@ import { Field, ObjectType } from '@nestjs/graphql'; import Stripe from 'stripe'; -import { SubscriptionInterval } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; - +import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; @ObjectType() export class ProductPriceEntity { @Field(() => SubscriptionInterval) diff --git a/packages/twenty-server/src/engine/core-modules/billing/dto/product.input.ts b/packages/twenty-server/src/engine/core-modules/billing/dto/product.input.ts index f58f681e01..126e1351d0 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/dto/product.input.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/dto/product.input.ts @@ -2,7 +2,7 @@ import { ArgsType, Field } from '@nestjs/graphql'; import { IsNotEmpty, IsString } from 'class-validator'; -import { AvailableProduct } from 'src/engine/core-modules/billing/interfaces/available-product.interface'; +import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum'; @ArgsType() export class ProductInput { diff --git a/packages/twenty-server/src/engine/core-modules/billing/entities/billing-entitlement.entity.ts b/packages/twenty-server/src/engine/core-modules/billing/entities/billing-entitlement.entity.ts new file mode 100644 index 0000000000..4bb831fdcb --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/entities/billing-entitlement.entity.ts @@ -0,0 +1,56 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { IDField } from '@ptc-org/nestjs-query-graphql'; +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Relation, + Unique, + UpdateDateColumn, +} from 'typeorm'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +@Entity({ name: 'billingEntitlement', schema: 'core' }) +@ObjectType('billingEntitlement') +@Unique('IndexOnFeatureKeyAndWorkspaceIdUnique', ['key', 'workspaceId']) +export class BillingEntitlement { + @IDField(() => UUIDScalarType) + @PrimaryGeneratedColumn('uuid') + id: string; + + @Field(() => String) + @Column({ nullable: false, type: 'text' }) + key: BillingEntitlementKey; + + @Field() + @Column({ nullable: false, type: 'uuid' }) + workspaceId: string; + + @ManyToOne(() => Workspace, (workspace) => workspace.billingEntitlements, { + onDelete: 'CASCADE', + }) + @JoinColumn() + workspace: Relation; + + @Column({ nullable: false }) + stripeCustomerId: string; + + @Field() + @Column({ nullable: false }) + value: boolean; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; + + @Column({ nullable: true, type: 'timestamptz' }) + deletedAt?: Date; +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription.entity.ts b/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription.entity.ts index 5199137d73..711c3f5275 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/entities/billing-subscription.entity.ts @@ -16,26 +16,10 @@ import { import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; +import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; +import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; -export enum SubscriptionStatus { - Active = 'active', - Canceled = 'canceled', - Incomplete = 'incomplete', - IncompleteExpired = 'incomplete_expired', - PastDue = 'past_due', - Paused = 'paused', - Trialing = 'trialing', - Unpaid = 'unpaid', -} - -export enum SubscriptionInterval { - Day = 'day', - Month = 'month', - Week = 'week', - Year = 'year', -} - registerEnumType(SubscriptionStatus, { name: 'SubscriptionStatus' }); registerEnumType(SubscriptionInterval, { name: 'SubscriptionInterval' }); diff --git a/packages/twenty-server/src/engine/core-modules/billing/interfaces/available-product.interface.ts b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-available-product.enum.ts similarity index 100% rename from packages/twenty-server/src/engine/core-modules/billing/interfaces/available-product.interface.ts rename to packages/twenty-server/src/engine/core-modules/billing/enums/billing-available-product.enum.ts diff --git a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-entitlement-key.enum.ts b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-entitlement-key.enum.ts new file mode 100644 index 0000000000..f4301c63d9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-entitlement-key.enum.ts @@ -0,0 +1,3 @@ +export enum BillingEntitlementKey { + SSO = 'SSO', +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-subscription-interval.enum.ts b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-subscription-interval.enum.ts new file mode 100644 index 0000000000..fe81cf3f40 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-subscription-interval.enum.ts @@ -0,0 +1,6 @@ +export enum SubscriptionInterval { + Day = 'day', + Month = 'month', + Week = 'week', + Year = 'year', +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-subscription-status.enum.ts b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-subscription-status.enum.ts new file mode 100644 index 0000000000..dc9621becb --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-subscription-status.enum.ts @@ -0,0 +1,10 @@ +export enum SubscriptionStatus { + Active = 'active', + Canceled = 'canceled', + Incomplete = 'incomplete', + IncompleteExpired = 'incomplete_expired', + PastDue = 'past_due', + Paused = 'paused', + Trialing = 'trialing', + Unpaid = 'unpaid', +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/enums/billing-webhook-events.enum.ts b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-webhook-events.enum.ts new file mode 100644 index 0000000000..efb1e5f571 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/enums/billing-webhook-events.enum.ts @@ -0,0 +1,7 @@ +export enum WebhookEvent { + CUSTOMER_SUBSCRIPTION_CREATED = 'customer.subscription.created', + CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated', + CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted', + SETUP_INTENT_SUCCEEDED = 'setup_intent.succeeded', + CUSTOMER_ACTIVE_ENTITLEMENT_SUMMARY_UPDATED = 'entitlements.active_entitlement_summary.updated', +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts index 6c031463f6..dbad2efc76 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-portal.workspace-service.ts @@ -12,13 +12,6 @@ import { User } from 'src/engine/core-modules/user/user.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { assert } from 'src/utils/assert'; -export enum WebhookEvent { - CUSTOMER_SUBSCRIPTION_CREATED = 'customer.subscription.created', - CUSTOMER_SUBSCRIPTION_UPDATED = 'customer.subscription.updated', - CUSTOMER_SUBSCRIPTION_DELETED = 'customer.subscription.deleted', - SETUP_INTENT_SUCCEEDED = 'setup_intent.succeeded', -} - @Injectable() export class BillingPortalWorkspaceService { protected readonly logger = new Logger(BillingPortalWorkspaceService.name); diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts index 248d3fa6eb..f2ebf2d07a 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-subscription.service.ts @@ -5,19 +5,15 @@ import assert from 'assert'; import { User } from '@sentry/node'; import Stripe from 'stripe'; -import { In, Not, Repository } from 'typeorm'; +import { Not, Repository } from 'typeorm'; -import { AvailableProduct } from 'src/engine/core-modules/billing/interfaces/available-product.interface'; - -import { - BillingSubscription, - SubscriptionInterval, - SubscriptionStatus, -} from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; +import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity'; +import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; +import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum'; +import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; +import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum'; +import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; -import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; -import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; -import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; @Injectable() @@ -26,59 +22,12 @@ export class BillingSubscriptionService { constructor( private readonly stripeService: StripeService, private readonly environmentService: EnvironmentService, + @InjectRepository(BillingEntitlement, 'core') + private readonly billingEntitlementRepository: Repository, @InjectRepository(BillingSubscription, 'core') private readonly billingSubscriptionRepository: Repository, - @InjectRepository(FeatureFlagEntity, 'core') - private readonly featureFlagRepository: Repository, - @InjectRepository(Workspace, 'core') - private readonly workspaceRepository: Repository, ) {} - /** - * @deprecated This is fully deprecated, it's only used in the migration script for 0.23 - */ - async getActiveSubscriptionWorkspaceIds() { - if (!this.environmentService.get('IS_BILLING_ENABLED')) { - return (await this.workspaceRepository.find({ select: ['id'] })).map( - (workspace) => workspace.id, - ); - } - - const activeSubscriptions = await this.billingSubscriptionRepository.find({ - where: { - status: In([ - SubscriptionStatus.Active, - SubscriptionStatus.Trialing, - SubscriptionStatus.PastDue, - ]), - }, - select: ['workspaceId'], - }); - - const freeAccessFeatureFlags = await this.featureFlagRepository.find({ - where: { - key: FeatureFlagKey.IsFreeAccessEnabled, - value: true, - }, - select: ['workspaceId'], - }); - - const activeWorkspaceIdsBasedOnSubscriptions = activeSubscriptions.map( - (subscription) => subscription.workspaceId, - ); - - const activeWorkspaceIdsBasedOnFeatureFlags = freeAccessFeatureFlags.map( - (featureFlag) => featureFlag.workspaceId, - ); - - return Array.from( - new Set([ - ...activeWorkspaceIdsBasedOnSubscriptions, - ...activeWorkspaceIdsBasedOnFeatureFlags, - ]), - ); - } - async getCurrentBillingSubscriptionOrThrow(criteria: { workspaceId?: string; stripeCustomerId?: string; @@ -148,6 +97,23 @@ export class BillingSubscriptionService { } } + async getWorkspaceEntitlementByKey( + workspaceId: string, + key: BillingEntitlementKey, + ) { + const entitlement = await this.billingEntitlementRepository.findOneBy({ + workspaceId, + key, + value: true, + }); + + if (!entitlement) { + return false; + } + + return entitlement.value; + } + async applyBillingSubscription(user: User) { const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow( { workspaceId: user.defaultWorkspaceId }, diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook.service.ts index 7d1b5df5a1..fc6c7d2b4c 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-webhook.service.ts @@ -4,11 +4,15 @@ import { InjectRepository } from '@nestjs/typeorm'; import Stripe from 'stripe'; import { Repository } from 'typeorm'; -import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; import { - BillingSubscription, - SubscriptionStatus, -} from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; + BillingException, + BillingExceptionCode, +} from 'src/engine/core-modules/billing/billing.exception'; +import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity'; +import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; +import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; +import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; +import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; import { Workspace, WorkspaceActivationStatus, @@ -20,6 +24,8 @@ export class BillingWebhookService { constructor( @InjectRepository(BillingSubscription, 'core') private readonly billingSubscriptionRepository: Repository, + @InjectRepository(BillingEntitlement, 'core') + private readonly billingEntitlementRepository: Repository, @InjectRepository(BillingSubscriptionItem, 'core') private readonly billingSubscriptionItemRepository: Repository, @InjectRepository(Workspace, 'core') @@ -43,7 +49,7 @@ export class BillingWebhookService { await this.billingSubscriptionRepository.upsert( { - workspaceId: workspaceId, + workspaceId, stripeCustomerId: data.object.customer as string, stripeSubscriptionId: data.object.id, status: data.object.status as SubscriptionStatus, @@ -96,4 +102,42 @@ export class BillingWebhookService { }); } } + + async processCustomerActiveEntitlement( + data: Stripe.EntitlementsActiveEntitlementSummaryUpdatedEvent.Data, + ) { + const billingSubscription = + await this.billingSubscriptionRepository.findOne({ + where: { stripeCustomerId: data.object.customer }, + }); + + if (!billingSubscription) { + throw new BillingException( + 'Billing customer not found', + BillingExceptionCode.BILLING_CUSTOMER_NOT_FOUND, + ); + } + + const workspaceId = billingSubscription.workspaceId; + const stripeCustomerId = data.object.customer; + + const activeEntitlementsKeys = data.object.entitlements.data.map( + (entitlement) => entitlement.lookup_key, + ); + + await this.billingEntitlementRepository.upsert( + Object.values(BillingEntitlementKey).map((key) => { + return { + workspaceId, + key, + value: activeEntitlementsKeys.includes(key), + stripeCustomerId, + }; + }), + { + conflictPaths: ['workspaceId', 'key'], + skipUpdateIfNoValuesChanged: true, + }, + ); + } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts index 8004d46e5c..27cc61bb4a 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing.service.ts @@ -2,11 +2,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { isDefined } from 'class-validator'; -import { SubscriptionStatus } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; +import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; +import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; -import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; @Injectable() export class BillingService { @@ -52,4 +53,20 @@ export class BillingService { ].includes(currentBillingSubscription.status) ); } + + async verifyWorkspaceEntitlement( + workspaceId: string, + entitlementKey: BillingEntitlementKey, + ) { + const isBillingEnabled = this.isBillingEnabled(); + + if (!isBillingEnabled) { + return true; + } + + return this.billingSubscriptionService.getWorkspaceEntitlementByKey( + workspaceId, + entitlementKey, + ); + } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts index a6b0d17993..2761415d0b 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/stripe.service.ts @@ -2,10 +2,9 @@ import { Injectable, Logger } from '@nestjs/common'; import Stripe from 'stripe'; -import { AvailableProduct } from 'src/engine/core-modules/billing/interfaces/available-product.interface'; - import { ProductPriceEntity } from 'src/engine/core-modules/billing/dto/product-price.entity'; import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; +import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { User } from 'src/engine/core-modules/user/user.entity'; @@ -15,6 +14,10 @@ export class StripeService { private readonly stripe: Stripe; constructor(private readonly environmentService: EnvironmentService) { + if (!this.environmentService.get('IS_BILLING_ENABLED')) { + return; + } + this.stripe = new Stripe( this.environmentService.get('BILLING_STRIPE_API_KEY'), {}, diff --git a/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts b/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts index 7b2148d230..e6d073316b 100644 --- a/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts +++ b/packages/twenty-server/src/engine/core-modules/sso/services/sso.service.ts @@ -6,9 +6,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Issuer } from 'openid-client'; import { Repository } from 'typeorm'; -import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator'; -import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service'; -import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum'; +import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum'; +import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; @@ -30,8 +29,8 @@ import { import { User } from 'src/engine/core-modules/user/user.entity'; @Injectable() -// eslint-disable-next-line @nx/workspace-inject-workspace-repository export class SSOService { + private readonly featureLookUpKey = BillingEntitlementKey.SSO; constructor( @InjectRepository(FeatureFlagEntity, 'core') private readonly featureFlagRepository: Repository, @@ -40,8 +39,7 @@ export class SSOService { @InjectRepository(User, 'core') private readonly userRepository: Repository, private readonly environmentService: EnvironmentService, - @InjectCacheStorage(CacheStorageNamespace.EngineWorkspace) - private readonly cacheStorageService: CacheStorageService, + private readonly billingService: BillingService, ) {} private async isSSOEnabled(workspaceId: string) { @@ -57,6 +55,18 @@ export class SSOService { SSOExceptionCode.SSO_DISABLE, ); } + const isSSOBillingEnabled = + await this.billingService.verifyWorkspaceEntitlement( + workspaceId, + this.featureLookUpKey, + ); + + if (!isSSOBillingEnabled) { + throw new SSOException( + `${FeatureFlagKey.IsSSOEnabled} feature is enabled but no entitlement for this workspace`, + SSOExceptionCode.SSO_DISABLE, + ); + } } async createOIDCIdentityProvider( diff --git a/packages/twenty-server/src/engine/core-modules/sso/sso.module.ts b/packages/twenty-server/src/engine/core-modules/sso/sso.module.ts index fc7fe99799..d51a566d3a 100644 --- a/packages/twenty-server/src/engine/core-modules/sso/sso.module.ts +++ b/packages/twenty-server/src/engine/core-modules/sso/sso.module.ts @@ -5,6 +5,7 @@ import { Module } from '@nestjs/common'; import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { SSOResolver } from 'src/engine/core-modules/sso/sso.resolver'; @@ -17,6 +18,7 @@ import { User } from 'src/engine/core-modules/user/user.entity'; [WorkspaceSSOIdentityProvider, User, AppToken, FeatureFlagEntity], 'core', ), + BillingModule, ], exports: [SSOService], providers: [SSOService, SSOResolver], diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index ac5c382871..ec75e081f4 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -13,13 +13,14 @@ import { import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; +import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity'; import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity'; import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity'; +import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { User } from 'src/engine/core-modules/user/user.entity'; -import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; export enum WorkspaceActivationStatus { ONGOING_CREATION = 'ONGOING_CREATION', @@ -38,6 +39,9 @@ registerEnumType(WorkspaceActivationStatus, { @UnPagedRelation('billingSubscriptions', () => BillingSubscription, { nullable: true, }) +@UnPagedRelation('billingEntitlements', () => BillingEntitlement, { + nullable: true, +}) export class Workspace { @IDField(() => UUIDScalarType) @PrimaryGeneratedColumn('uuid') @@ -117,6 +121,12 @@ export class Workspace { ) billingSubscriptions: Relation; + @OneToMany( + () => BillingEntitlement, + (billingEntitlement) => billingEntitlement.workspace, + ) + billingEntitlements: Relation; + @OneToMany( () => PostgresCredentials, (postgresCredentials) => postgresCredentials.workspace, diff --git a/yarn.lock b/yarn.lock index 289dd0c566..94ebc74969 100644 --- a/yarn.lock +++ b/yarn.lock @@ -42868,13 +42868,13 @@ __metadata: languageName: node linkType: hard -"stripe@npm:^14.17.0": - version: 14.25.0 - resolution: "stripe@npm:14.25.0" +"stripe@npm:^17.3.1": + version: 17.3.1 + resolution: "stripe@npm:17.3.1" dependencies: "@types/node": "npm:>=8.1.0" qs: "npm:^6.11.0" - checksum: 10c0/3f98230d537bdcb9e31775576743e9f2e2137d45021b3a59afe5af17dc54397e8f27bab7abce6fbb81545f69dc73f4c1325c987d2e0c88c2149e135c783d14ff + checksum: 10c0/96c9595428775d3bb5d619f770dd4775357ec778be4033915d629ef6033d29a70ec4d916311cc2e1e5e5e45646d9b53fcef6215e60e1ea183d89554bd61e7055 languageName: node linkType: hard @@ -44629,7 +44629,7 @@ __metadata: storybook-addon-cookie: "npm:^3.2.0" storybook-addon-pseudo-states: "npm:^2.1.2" storybook-dark-mode: "npm:^3.0.3" - stripe: "npm:^14.17.0" + stripe: "npm:^17.3.1" supertest: "npm:^6.1.3" ts-jest: "npm:^29.1.1" ts-key-enum: "npm:^2.0.12"