martmull
2025-06-05 20:56:55 +02:00
committed by GitHub
parent c75f10bc33
commit b2c57c5dcc
29 changed files with 650 additions and 237 deletions

View File

@ -25,4 +25,5 @@ export enum BillingExceptionCode {
BILLING_STRIPE_ERROR = 'BILLING_STRIPE_ERROR',
BILLING_SUBSCRIPTION_NOT_IN_TRIAL_PERIOD = 'BILLING_SUBSCRIPTION_NOT_IN_TRIAL_PERIOD',
BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE = 'BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE',
BILLING_SUBSCRIPTION_PLAN_NOT_SWITCHABLE = 'BILLING_SUBSCRIPTION_PLAN_NOT_SWITCHABLE',
}

View File

@ -124,6 +124,17 @@ export class BillingResolver {
return { success: true };
}
@Mutation(() => BillingUpdateOutput)
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionsGuard(SettingPermissionType.WORKSPACE),
)
async switchToEnterprisePlan(@AuthWorkspace() workspace: Workspace) {
await this.billingSubscriptionService.switchToEnterprisePlan(workspace);
return { success: true };
}
@Query(() => [BillingPlanOutput])
@UseGuards(WorkspaceAuthGuard)
async plans(): Promise<BillingPlanOutput[]> {

View File

@ -15,6 +15,9 @@ export class BillingSubscriptionItemDTO {
@Field(() => Boolean)
hasReachedCurrentPeriodCap: boolean;
@Field(() => Number, { nullable: true })
quantity: number | null;
@Field(() => BillingProductDTO, { nullable: true })
billingProduct: BillingProductDTO;
}

View File

@ -16,6 +16,7 @@ import {
Relation,
UpdateDateColumn,
} from 'typeorm';
import graphqlTypeJson from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { BillingSubscriptionItemDTO } from 'src/engine/core-modules/billing/dtos/outputs/billing-subscription-item.output';
@ -115,6 +116,7 @@ export class BillingSubscription {
})
currentPeriodStart: Date;
@Field(() => graphqlTypeJson)
@Column({ nullable: false, type: 'jsonb', default: {} })
metadata: Stripe.Metadata;

View File

@ -55,6 +55,8 @@ export class BillingRestApiExceptionFilter implements ExceptionFilter {
case BillingExceptionCode.BILLING_METER_EVENT_FAILED:
case BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_IN_TRIAL_PERIOD:
case BillingExceptionCode.BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE:
case BillingExceptionCode.BILLING_SUBSCRIPTION_PLAN_NOT_SWITCHABLE:
case BillingExceptionCode.BILLING_MISSING_REQUEST_BODY:
return this.httpExceptionHandlerService.handleError(
exception,
response,

View File

@ -66,7 +66,7 @@ export class BillingPlanService {
where: {
active: true,
},
relations: ['billingPrices'],
relations: ['billingPrices.billingProduct'],
});
return planKeys.map((planKey) => {

View File

@ -11,25 +11,39 @@ import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
@Injectable()
export class BillingProductService {
protected readonly logger = new Logger(BillingProductService.name);
constructor(private readonly billingPlanService: BillingPlanService) {}
getProductPricesByInterval({
async getProductPrices({
interval,
planKey,
}: {
interval: SubscriptionInterval;
planKey: BillingPlanKey;
}): Promise<BillingPrice[]> {
const billingProducts = await this.getProductsByPlan(planKey);
return this.getProductPricesByInterval({
interval,
billingProductsByPlan: billingProducts,
});
}
private getProductPricesByInterval({
interval,
billingProductsByPlan,
}: {
interval: SubscriptionInterval;
billingProductsByPlan: BillingProduct[];
}): BillingPrice[] {
const billingPrices = billingProductsByPlan.flatMap((product) =>
return billingProductsByPlan.flatMap((product) =>
product.billingPrices.filter(
(price) => price.interval === interval && price.active,
),
);
return billingPrices;
}
async getProductsByPlan(planKey: BillingPlanKey): Promise<BillingProduct[]> {

View File

@ -31,6 +31,8 @@ import { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/util
import { getSubscriptionStatus } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
@Injectable()
export class BillingSubscriptionService {
protected readonly logger = new Logger(BillingSubscriptionService.name);
@ -166,15 +168,14 @@ export class BillingSubscriptionService {
);
}
const newInterval = SubscriptionInterval.Year;
const interval = SubscriptionInterval.Year;
const planKey = getPlanKeyFromSubscription(billingSubscription);
const billingProductsByPlan =
await this.billingProductService.getProductsByPlan(planKey);
const pricesPerPlanArray =
this.billingProductService.getProductPricesByInterval({
interval: newInterval,
billingProductsByPlan,
await this.billingProductService.getProductPrices({
interval,
planKey,
});
const subscriptionItemsToUpdate = this.getSubscriptionItemsToUpdate(
@ -188,14 +189,54 @@ export class BillingSubscriptionService {
);
}
async switchToEnterprisePlan(workspace: Workspace) {
const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow(
{ workspaceId: workspace.id },
);
if (billingSubscription.metadata?.plan === BillingPlanKey.ENTERPRISE) {
throw new BillingException(
'Cannot switch from Organization to Pro plan',
BillingExceptionCode.BILLING_SUBSCRIPTION_PLAN_NOT_SWITCHABLE,
);
}
const planKey = BillingPlanKey.ENTERPRISE;
const interval = billingSubscription.interval as SubscriptionInterval;
const pricesPerPlanArray =
await this.billingProductService.getProductPrices({
interval,
planKey,
});
const subscriptionItemsToUpdate = this.getSubscriptionItemsToUpdate(
billingSubscription,
pricesPerPlanArray,
);
await this.stripeSubscriptionService.updateSubscriptionItems(
billingSubscription.stripeSubscriptionId,
subscriptionItemsToUpdate,
);
await this.stripeSubscriptionService.updateSubscription(
billingSubscription.stripeSubscriptionId,
{ metadata: { ...billingSubscription?.metadata, plan: planKey } },
);
}
private getSubscriptionItemsToUpdate(
billingSubscription: BillingSubscription,
billingPricesPerPlanAndIntervalArray: BillingPrice[],
): BillingSubscriptionItem[] {
const subscriptionItemsToUpdate =
billingSubscription.billingSubscriptionItems.map((subscriptionItem) => {
return billingSubscription.billingSubscriptionItems.map(
(subscriptionItem) => {
const matchingPrice = billingPricesPerPlanAndIntervalArray.find(
(price) => price.stripeProductId === subscriptionItem.stripeProductId,
(price) =>
price.billingProduct.metadata.priceUsageBased ===
subscriptionItem.billingProduct.metadata.priceUsageBased,
);
if (!matchingPrice) {
@ -208,10 +249,10 @@ export class BillingSubscriptionService {
return {
...subscriptionItem,
stripePriceId: matchingPrice.stripePriceId,
stripeProductId: matchingPrice.stripeProductId,
};
});
return subscriptionItemsToUpdate;
},
);
}
async endTrialPeriod(workspace: Workspace) {