Update billing page ctas (#12459)
## Before  ## After <img width="1056" alt="image" src="https://github.com/user-attachments/assets/4a51b7c7-898b-485f-95e8-97911292f2b1" /> <img width="1299" alt="image" src="https://github.com/user-attachments/assets/44e5e545-a660-455a-91be-3b139ccb9f30" /> <img width="1180" alt="image" src="https://github.com/user-attachments/assets/0ca765a7-1d9a-473a-b7d2-c6f9b1a72417" /> <img width="963" alt="image" src="https://github.com/user-attachments/assets/b620fd8a-61c9-4dd3-a3b1-e4ba940371e4" /> <img width="863" alt="image" src="https://github.com/user-attachments/assets/a0d2dcb5-19e5-4f83-80d4-ad5a715f1e5f" /> --------- Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
This commit is contained in:
@ -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',
|
||||
}
|
||||
|
||||
@ -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[]> {
|
||||
|
||||
@ -15,6 +15,9 @@ export class BillingSubscriptionItemDTO {
|
||||
@Field(() => Boolean)
|
||||
hasReachedCurrentPeriodCap: boolean;
|
||||
|
||||
@Field(() => Number, { nullable: true })
|
||||
quantity: number | null;
|
||||
|
||||
@Field(() => BillingProductDTO, { nullable: true })
|
||||
billingProduct: BillingProductDTO;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -66,7 +66,7 @@ export class BillingPlanService {
|
||||
where: {
|
||||
active: true,
|
||||
},
|
||||
relations: ['billingPrices'],
|
||||
relations: ['billingPrices.billingProduct'],
|
||||
});
|
||||
|
||||
return planKeys.map((planKey) => {
|
||||
|
||||
@ -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[]> {
|
||||
|
||||
@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user