mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-26 21:53:48 +03:00
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:
parent
cb5a0c1cc6
commit
35f2d7a004
@ -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",
|
||||
|
@ -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"`);
|
||||
}
|
||||
}
|
@ -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,
|
||||
],
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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',
|
||||
}
|
@ -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,
|
||||
|
@ -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';
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
}
|
@ -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' });
|
||||
|
||||
|
@ -0,0 +1,3 @@
|
||||
export enum BillingEntitlementKey {
|
||||
SSO = 'SSO',
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
export enum SubscriptionInterval {
|
||||
Day = 'day',
|
||||
Month = 'month',
|
||||
Week = 'week',
|
||||
Year = 'year',
|
||||
}
|
@ -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',
|
||||
}
|
@ -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',
|
||||
}
|
@ -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);
|
||||
|
@ -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<BillingEntitlement>,
|
||||
@InjectRepository(BillingSubscription, 'core')
|
||||
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: {
|
||||
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 },
|
||||
|
@ -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<BillingSubscription>,
|
||||
@InjectRepository(BillingEntitlement, 'core')
|
||||
private readonly billingEntitlementRepository: Repository<BillingEntitlement>,
|
||||
@InjectRepository(BillingSubscriptionItem, 'core')
|
||||
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
|
||||
@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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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'),
|
||||
{},
|
||||
|
@ -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<FeatureFlagEntity>,
|
||||
@ -40,8 +39,7 @@ export class SSOService {
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
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(
|
||||
|
@ -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],
|
||||
|
@ -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<BillingSubscription[]>;
|
||||
|
||||
@OneToMany(
|
||||
() => BillingEntitlement,
|
||||
(billingEntitlement) => billingEntitlement.workspace,
|
||||
)
|
||||
billingEntitlements: Relation<BillingEntitlement[]>;
|
||||
|
||||
@OneToMany(
|
||||
() => PostgresCredentials,
|
||||
(postgresCredentials) => postgresCredentials.workspace,
|
||||
|
10
yarn.lock
10
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"
|
||||
|
Loading…
Reference in New Issue
Block a user