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 7ccd6142a..c65602f25 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 @@ -16,6 +16,7 @@ import { BillingRestApiExceptionFilter } from 'src/engine/core-modules/billing/f import { BillingWorkspaceMemberListener } from 'src/engine/core-modules/billing/listeners/billing-workspace-member.listener'; import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service'; import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service'; +import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module'; @@ -58,6 +59,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; BillingWebhookSubscriptionService, BillingWebhookEntitlementService, BillingPortalWorkspaceService, + BillingProductService, BillingResolver, BillingPlanService, BillingWorkspaceMemberListener, diff --git a/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts b/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts index b81d67b11..a57177370 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/jobs/update-subscription-quantity.job.ts @@ -36,13 +36,13 @@ export class UpdateSubscriptionQuantityJob { } try { - const billingSubscriptionItem = - await this.billingSubscriptionService.getCurrentBillingSubscriptionItemOrThrow( + const billingBaseProductSubscriptionItem = + await this.billingSubscriptionService.getBaseProductCurrentBillingSubscriptionItemOrThrow( data.workspaceId, ); await this.stripeSubscriptionItemService.updateSubscriptionItem( - billingSubscriptionItem.stripeSubscriptionItemId, + billingBaseProductSubscriptionItem.stripeSubscriptionItemId, workspaceMembersCount, ); diff --git a/packages/twenty-server/src/engine/core-modules/billing/services/billing-product.service.ts b/packages/twenty-server/src/engine/core-modules/billing/services/billing-product.service.ts new file mode 100644 index 000000000..0f68b9996 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/services/billing-product.service.ts @@ -0,0 +1,44 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { + BillingException, + BillingExceptionCode, +} from 'src/engine/core-modules/billing/billing.exception'; +import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity'; +import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity'; +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({ + interval, + billingProductsByPlan, + }: { + interval: SubscriptionInterval; + billingProductsByPlan: BillingProduct[]; + }): BillingPrice[] { + const billingPrices = billingProductsByPlan.flatMap((product) => + product.billingPrices.filter((price) => price.interval === interval), + ); + + return billingPrices; + } + + async getProductsByPlan(planKey: BillingPlanKey): Promise { + const products = await this.billingPlanService.getPlans(); + const plan = products.find((product) => product.planKey === planKey); + + if (!plan) { + throw new BillingException( + `Plan ${planKey} not found`, + BillingExceptionCode.BILLING_PLAN_NOT_FOUND, + ); + } + + return [plan.baseProduct, ...plan.meteredProducts]; + } +} 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 7378f3f8b..1b03da3b8 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 @@ -11,6 +11,8 @@ import { BillingExceptionCode, } from 'src/engine/core-modules/billing/billing.exception'; import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity'; +import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.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 { 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'; @@ -18,6 +20,7 @@ import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-pl 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 { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service'; +import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service'; import { StripePriceService } from 'src/engine/core-modules/billing/stripe/services/stripe-price.service'; import { StripeSubscriptionItemService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-item.service'; import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service'; @@ -35,6 +38,7 @@ export class BillingSubscriptionService { private readonly billingPlanService: BillingPlanService, private readonly environmentService: EnvironmentService, private readonly featureFlagService: FeatureFlagService, + private readonly billingProductService: BillingProductService, @InjectRepository(BillingEntitlement, 'core') private readonly billingEntitlementRepository: Repository, @InjectRepository(BillingSubscription, 'core') @@ -59,9 +63,9 @@ export class BillingSubscriptionService { return notCanceledSubscriptions?.[0]; } - async getCurrentBillingSubscriptionItemOrThrow( + async getBaseProductCurrentBillingSubscriptionItemOrThrow( workspaceId: string, - stripeProductId = this.environmentService.get( + stripeBaseProductId = this.environmentService.get( 'BILLING_STRIPE_BASE_PLAN_PRODUCT_ID', ), ) { @@ -78,7 +82,7 @@ export class BillingSubscriptionService { const getStripeProductId = isBillingPlansEnabled ? (await this.billingPlanService.getPlanBaseProduct(BillingPlanKey.PRO)) ?.stripeProductId - : stripeProductId; + : stripeBaseProductId; if (!getStripeProductId) { throw new BillingException( @@ -164,42 +168,71 @@ export class BillingSubscriptionService { ? SubscriptionInterval.Month : SubscriptionInterval.Year; - const billingSubscriptionItem = - await this.getCurrentBillingSubscriptionItemOrThrow(workspace.id); - - let productPrice; + const billingBaseProductSubscriptionItem = + await this.getBaseProductCurrentBillingSubscriptionItemOrThrow( + workspace.id, + ); if (isBillingPlansEnabled) { - const baseProduct = await this.billingPlanService.getPlanBaseProduct( - BillingPlanKey.PRO, + const billingProductsByPlan = + await this.billingProductService.getProductsByPlan(BillingPlanKey.PRO); + const pricesPerPlanArray = + this.billingProductService.getProductPricesByInterval({ + interval: newInterval, + billingProductsByPlan, + }); + + const subscriptionItemsToUpdate = this.getSubscriptionItemsToUpdate( + billingSubscription, + pricesPerPlanArray, ); - if (!baseProduct) { - throw new BillingException( - 'Base product not found', - BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND, - ); - } - - productPrice = baseProduct.billingPrices.find( - (price) => price.interval === newInterval, + await this.stripeSubscriptionService.updateSubscriptionItems( + billingSubscription.stripeSubscriptionId, + subscriptionItemsToUpdate, ); } else { - productPrice = await this.stripePriceService.getStripePrice( + const productPrice = await this.stripePriceService.getStripePrice( AvailableProduct.BasePlan, newInterval, ); - } - if (!productPrice) { - throw new Error( - `Cannot find product price for product ${AvailableProduct.BasePlan} and interval ${newInterval}`, + if (!productPrice) { + throw new Error( + `Cannot find product price for product ${AvailableProduct.BasePlan} and interval ${newInterval}`, + ); + } + + await this.stripeSubscriptionItemService.updateBillingSubscriptionItem( + billingBaseProductSubscriptionItem, + productPrice.stripePriceId, ); } + } - await this.stripeSubscriptionItemService.updateBillingSubscriptionItem( - billingSubscriptionItem, - productPrice.stripePriceId, - ); + private getSubscriptionItemsToUpdate( + billingSubscription: BillingSubscription, + billingPricesPerPlanAndIntervalArray: BillingPrice[], + ): BillingSubscriptionItem[] { + const subscriptionItemsToUpdate = + billingSubscription.billingSubscriptionItems.map((subscriptionItem) => { + const matchingPrice = billingPricesPerPlanAndIntervalArray.find( + (price) => price.stripeProductId === subscriptionItem.stripeProductId, + ); + + if (!matchingPrice) { + throw new BillingException( + `Cannot find matching price for product ${subscriptionItem.stripeProductId}`, + BillingExceptionCode.BILLING_PRICE_NOT_FOUND, + ); + } + + return { + ...subscriptionItem, + stripePriceId: matchingPrice.stripePriceId, + }; + }); + + return subscriptionItemsToUpdate; } } diff --git a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts index 686aafe47..56a33ce74 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/stripe/services/stripe-subscription.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import Stripe from 'stripe'; +import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity'; import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; @@ -56,4 +57,21 @@ export class StripeSubscriptionService { } await this.stripe.invoices.pay(latestInvoice.id); } + + async updateSubscriptionItems( + stripeSubscriptionId: string, + billingSubscriptionItems: BillingSubscriptionItem[], + ) { + const stripeSubscriptionItemsToUpdate = billingSubscriptionItems.map( + (item) => ({ + id: item.stripeSubscriptionItemId, + price: item.stripePriceId, + quantity: item.quantity === null ? undefined : item.quantity, + }), + ); + + await this.stripe.subscriptions.update(stripeSubscriptionId, { + items: stripeSubscriptionItemsToUpdate, + }); + } }