Add entitlement table and sso stripe feature (#8608)

**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 <felix@twenty.com>
This commit is contained in:
Ana Sofia Marin Alexandre 2024-11-22 11:32:48 -03:00 committed by GitHub
parent cb5a0c1cc6
commit 35f2d7a004
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 287 additions and 117 deletions

View File

@ -179,7 +179,7 @@
"semver": "^7.5.4", "semver": "^7.5.4",
"sharp": "^0.32.1", "sharp": "^0.32.1",
"slash": "^5.1.0", "slash": "^5.1.0",
"stripe": "^14.17.0", "stripe": "^17.3.1",
"ts-key-enum": "^2.0.12", "ts-key-enum": "^2.0.12",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"tsup": "^8.2.4", "tsup": "^8.2.4",

View File

@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBillingEntitlementTable1732098580545
implements MigrationInterface
{
name = 'AddBillingEntitlementTable1732098580545';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(
`ALTER TABLE "core"."billingEntitlement" DROP CONSTRAINT "FK_599121a93d8177b5d713b941982"`,
);
await queryRunner.query(`DROP TABLE "core"."billingEntitlement"`);
}
}

View File

@ -3,6 +3,7 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; 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 { 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 { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; 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 { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity'; import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
@Injectable() @Injectable()
export class TypeORMService implements OnModuleInit, OnModuleDestroy { export class TypeORMService implements OnModuleInit, OnModuleDestroy {
private mainDataSource: DataSource; private mainDataSource: DataSource;
@ -36,6 +36,7 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy {
FeatureFlagEntity, FeatureFlagEntity,
BillingSubscription, BillingSubscription,
BillingSubscriptionItem, BillingSubscriptionItem,
BillingEntitlement,
PostgresCredentials, PostgresCredentials,
WorkspaceSSOIdentityProvider, WorkspaceSSOIdentityProvider,
], ],

View File

@ -10,19 +10,22 @@ import {
import { Response } from 'express'; 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 { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingWebhookService } from 'src/engine/core-modules/billing/services/billing-webhook.service'; import { BillingWebhookService } from 'src/engine/core-modules/billing/services/billing-webhook.service';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
@Controller('billing') @Controller('billing')
export class BillingController { export class BillingController {
protected readonly logger = new Logger(BillingController.name); protected readonly logger = new Logger(BillingController.name);
constructor( constructor(
private readonly stripeService: StripeService, private readonly stripeService: StripeService,
private readonly billingSubscriptionService: BillingSubscriptionService,
private readonly billingWehbookService: BillingWebhookService, private readonly billingWehbookService: BillingWebhookService,
private readonly billingSubscriptionService: BillingSubscriptionService,
) {} ) {}
@Post('/webhooks') @Post('/webhooks')
@ -63,6 +66,22 @@ export class BillingController {
event.data, 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(); res.status(200).end();
} }
} }

View File

@ -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',
}

View File

@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingController } from 'src/engine/core-modules/billing/billing.controller'; import { BillingController } from 'src/engine/core-modules/billing/billing.controller';
import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver'; 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 { 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 { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingWorkspaceMemberListener } from 'src/engine/core-modules/billing/listeners/billing-workspace-member.listener'; 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, BillingSubscription,
BillingSubscriptionItem, BillingSubscriptionItem,
BillingEntitlement,
Workspace, Workspace,
UserWorkspace, UserWorkspace,
FeatureFlagEntity, FeatureFlagEntity,

View File

@ -1,14 +1,13 @@
import { UseGuards } from '@nestjs/common'; import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; 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 { BillingSessionInput } from 'src/engine/core-modules/billing/dto/billing-session.input';
import { CheckoutSessionInput } from 'src/engine/core-modules/billing/dto/checkout-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 { ProductPricesEntity } from 'src/engine/core-modules/billing/dto/product-prices.entity';
import { ProductInput } from 'src/engine/core-modules/billing/dto/product.input'; import { ProductInput } from 'src/engine/core-modules/billing/dto/product.input';
import { SessionEntity } from 'src/engine/core-modules/billing/dto/session.entity'; import { SessionEntity } from 'src/engine/core-modules/billing/dto/session.entity';
import { UpdateBillingEntity } from 'src/engine/core-modules/billing/dto/update-billing.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 { 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 { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';

View File

@ -3,7 +3,7 @@ import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import Stripe from 'stripe'; 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() @ArgsType()
export class CheckoutSessionInput { export class CheckoutSessionInput {

View File

@ -2,8 +2,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
import Stripe from 'stripe'; 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() @ObjectType()
export class ProductPriceEntity { export class ProductPriceEntity {
@Field(() => SubscriptionInterval) @Field(() => SubscriptionInterval)

View File

@ -2,7 +2,7 @@ import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator'; 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() @ArgsType()
export class ProductInput { export class ProductInput {

View File

@ -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<Workspace>;
@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;
}

View File

@ -16,26 +16,10 @@ import {
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; 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 { 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'; 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(SubscriptionStatus, { name: 'SubscriptionStatus' });
registerEnumType(SubscriptionInterval, { name: 'SubscriptionInterval' }); registerEnumType(SubscriptionInterval, { name: 'SubscriptionInterval' });

View File

@ -0,0 +1,3 @@
export enum BillingEntitlementKey {
SSO = 'SSO',
}

View File

@ -0,0 +1,6 @@
export enum SubscriptionInterval {
Day = 'day',
Month = 'month',
Week = 'week',
Year = 'year',
}

View File

@ -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',
}

View File

@ -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',
}

View File

@ -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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { assert } from 'src/utils/assert'; 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() @Injectable()
export class BillingPortalWorkspaceService { export class BillingPortalWorkspaceService {
protected readonly logger = new Logger(BillingPortalWorkspaceService.name); protected readonly logger = new Logger(BillingPortalWorkspaceService.name);

View File

@ -5,19 +5,15 @@ import assert from 'assert';
import { User } from '@sentry/node'; import { User } from '@sentry/node';
import Stripe from 'stripe'; 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 { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum';
BillingSubscription, import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
SubscriptionInterval, import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
SubscriptionStatus, import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
} from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service'; 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'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable() @Injectable()
@ -26,59 +22,12 @@ export class BillingSubscriptionService {
constructor( constructor(
private readonly stripeService: StripeService, private readonly stripeService: StripeService,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
@InjectRepository(BillingEntitlement, 'core')
private readonly billingEntitlementRepository: Repository<BillingEntitlement>,
@InjectRepository(BillingSubscription, 'core') @InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>, private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(FeatureFlagEntity, 'core')
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {} ) {}
/**
* @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: { async getCurrentBillingSubscriptionOrThrow(criteria: {
workspaceId?: string; workspaceId?: string;
stripeCustomerId?: 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) { async applyBillingSubscription(user: User) {
const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow( const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: user.defaultWorkspaceId }, { workspaceId: user.defaultWorkspaceId },

View File

@ -4,11 +4,15 @@ import { InjectRepository } from '@nestjs/typeorm';
import Stripe from 'stripe'; import Stripe from 'stripe';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
import { import {
BillingSubscription, BillingException,
SubscriptionStatus, BillingExceptionCode,
} from 'src/engine/core-modules/billing/entities/billing-subscription.entity'; } 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 { import {
Workspace, Workspace,
WorkspaceActivationStatus, WorkspaceActivationStatus,
@ -20,6 +24,8 @@ export class BillingWebhookService {
constructor( constructor(
@InjectRepository(BillingSubscription, 'core') @InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>, private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@InjectRepository(BillingEntitlement, 'core')
private readonly billingEntitlementRepository: Repository<BillingEntitlement>,
@InjectRepository(BillingSubscriptionItem, 'core') @InjectRepository(BillingSubscriptionItem, 'core')
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>, private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
@ -43,7 +49,7 @@ export class BillingWebhookService {
await this.billingSubscriptionRepository.upsert( await this.billingSubscriptionRepository.upsert(
{ {
workspaceId: workspaceId, workspaceId,
stripeCustomerId: data.object.customer as string, stripeCustomerId: data.object.customer as string,
stripeSubscriptionId: data.object.id, stripeSubscriptionId: data.object.id,
status: data.object.status as SubscriptionStatus, 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,
},
);
}
} }

View File

@ -2,11 +2,12 @@ import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'class-validator'; 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 { 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 { 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 { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable() @Injectable()
export class BillingService { export class BillingService {
@ -52,4 +53,20 @@ export class BillingService {
].includes(currentBillingSubscription.status) ].includes(currentBillingSubscription.status)
); );
} }
async verifyWorkspaceEntitlement(
workspaceId: string,
entitlementKey: BillingEntitlementKey,
) {
const isBillingEnabled = this.isBillingEnabled();
if (!isBillingEnabled) {
return true;
}
return this.billingSubscriptionService.getWorkspaceEntitlementByKey(
workspaceId,
entitlementKey,
);
}
} }

View File

@ -2,10 +2,9 @@ import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe'; 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 { 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 { 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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
@ -15,6 +14,10 @@ export class StripeService {
private readonly stripe: Stripe; private readonly stripe: Stripe;
constructor(private readonly environmentService: EnvironmentService) { constructor(private readonly environmentService: EnvironmentService) {
if (!this.environmentService.get('IS_BILLING_ENABLED')) {
return;
}
this.stripe = new Stripe( this.stripe = new Stripe(
this.environmentService.get('BILLING_STRIPE_API_KEY'), this.environmentService.get('BILLING_STRIPE_API_KEY'),
{}, {},

View File

@ -6,9 +6,8 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Issuer } from 'openid-client'; import { Issuer } from 'openid-client';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator'; import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service'; import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.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 { 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 { 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'; import { User } from 'src/engine/core-modules/user/user.entity';
@Injectable() @Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class SSOService { export class SSOService {
private readonly featureLookUpKey = BillingEntitlementKey.SSO;
constructor( constructor(
@InjectRepository(FeatureFlagEntity, 'core') @InjectRepository(FeatureFlagEntity, 'core')
private readonly featureFlagRepository: Repository<FeatureFlagEntity>, private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
@ -40,8 +39,7 @@ export class SSOService {
@InjectRepository(User, 'core') @InjectRepository(User, 'core')
private readonly userRepository: Repository<User>, private readonly userRepository: Repository<User>,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
@InjectCacheStorage(CacheStorageNamespace.EngineWorkspace) private readonly billingService: BillingService,
private readonly cacheStorageService: CacheStorageService,
) {} ) {}
private async isSSOEnabled(workspaceId: string) { private async isSSOEnabled(workspaceId: string) {
@ -57,6 +55,18 @@ export class SSOService {
SSOExceptionCode.SSO_DISABLE, 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( async createOIDCIdentityProvider(

View File

@ -5,6 +5,7 @@ import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; 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 { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service'; import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { SSOResolver } from 'src/engine/core-modules/sso/sso.resolver'; 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], [WorkspaceSSOIdentityProvider, User, AppToken, FeatureFlagEntity],
'core', 'core',
), ),
BillingModule,
], ],
exports: [SSOService], exports: [SSOService],
providers: [SSOService, SSOResolver], providers: [SSOService, SSOResolver],

View File

@ -13,13 +13,14 @@ import {
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; 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 { 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 { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.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 { 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 { 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 { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.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 { export enum WorkspaceActivationStatus {
ONGOING_CREATION = 'ONGOING_CREATION', ONGOING_CREATION = 'ONGOING_CREATION',
@ -38,6 +39,9 @@ registerEnumType(WorkspaceActivationStatus, {
@UnPagedRelation('billingSubscriptions', () => BillingSubscription, { @UnPagedRelation('billingSubscriptions', () => BillingSubscription, {
nullable: true, nullable: true,
}) })
@UnPagedRelation('billingEntitlements', () => BillingEntitlement, {
nullable: true,
})
export class Workspace { export class Workspace {
@IDField(() => UUIDScalarType) @IDField(() => UUIDScalarType)
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
@ -117,6 +121,12 @@ export class Workspace {
) )
billingSubscriptions: Relation<BillingSubscription[]>; billingSubscriptions: Relation<BillingSubscription[]>;
@OneToMany(
() => BillingEntitlement,
(billingEntitlement) => billingEntitlement.workspace,
)
billingEntitlements: Relation<BillingEntitlement[]>;
@OneToMany( @OneToMany(
() => PostgresCredentials, () => PostgresCredentials,
(postgresCredentials) => postgresCredentials.workspace, (postgresCredentials) => postgresCredentials.workspace,

View File

@ -42868,13 +42868,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"stripe@npm:^14.17.0": "stripe@npm:^17.3.1":
version: 14.25.0 version: 17.3.1
resolution: "stripe@npm:14.25.0" resolution: "stripe@npm:17.3.1"
dependencies: dependencies:
"@types/node": "npm:>=8.1.0" "@types/node": "npm:>=8.1.0"
qs: "npm:^6.11.0" qs: "npm:^6.11.0"
checksum: 10c0/3f98230d537bdcb9e31775576743e9f2e2137d45021b3a59afe5af17dc54397e8f27bab7abce6fbb81545f69dc73f4c1325c987d2e0c88c2149e135c783d14ff checksum: 10c0/96c9595428775d3bb5d619f770dd4775357ec778be4033915d629ef6033d29a70ec4d916311cc2e1e5e5e45646d9b53fcef6215e60e1ea183d89554bd61e7055
languageName: node languageName: node
linkType: hard linkType: hard
@ -44629,7 +44629,7 @@ __metadata:
storybook-addon-cookie: "npm:^3.2.0" storybook-addon-cookie: "npm:^3.2.0"
storybook-addon-pseudo-states: "npm:^2.1.2" storybook-addon-pseudo-states: "npm:^2.1.2"
storybook-dark-mode: "npm:^3.0.3" storybook-dark-mode: "npm:^3.0.3"
stripe: "npm:^14.17.0" stripe: "npm:^17.3.1"
supertest: "npm:^6.1.3" supertest: "npm:^6.1.3"
ts-jest: "npm:^29.1.1" ts-jest: "npm:^29.1.1"
ts-key-enum: "npm:^2.0.12" ts-key-enum: "npm:^2.0.12"