update subscription interval and quantity for hybrid susbscription (#9822)

Solves https://github.com/twentyhq/private-issues/issues/253

**TLDR:**

Can update the billing subscription interval for a subscription with a
base product and metered product. It also updates correctly as the
quantity of base products depending on how many people are in the
workspace.

**In order to test:**

1. Have the environment variable IS_BILLING_ENABLED set to true and add
the other required environment variables for Billing to work
2. Do a database reset (to ensure that the new feature flag is properly
added and that the billing tables are created)
3. Run the command: npx nx run twenty-server:command
billing:sync-plans-data (if you don't do that the products and prices
will not be present in the database)
4. Run the server , the frontend, the worker, and the stripe listen
command (stripe listen --forward-to
http://localhost:3000/billing/webhooks)
5. Buy a subscription for the Acme workspace , change the interval in
the Billing Settings
6. Add another person to the workspace, you should see all the previous
changes reflected in the database

**Doing**
Moving the BillingSubscriptionsService.getUpdatedSubscriptionItems to an
util (for a less cluttered service)
This commit is contained in:
Ana Sofia Marin Alexandre
2025-01-24 14:19:09 -03:00
committed by GitHub
parent c89cc38729
commit f58f84114c
5 changed files with 127 additions and 30 deletions

View File

@ -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 { 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 { 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 { 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 { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module'; 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, BillingWebhookSubscriptionService,
BillingWebhookEntitlementService, BillingWebhookEntitlementService,
BillingPortalWorkspaceService, BillingPortalWorkspaceService,
BillingProductService,
BillingResolver, BillingResolver,
BillingPlanService, BillingPlanService,
BillingWorkspaceMemberListener, BillingWorkspaceMemberListener,

View File

@ -36,13 +36,13 @@ export class UpdateSubscriptionQuantityJob {
} }
try { try {
const billingSubscriptionItem = const billingBaseProductSubscriptionItem =
await this.billingSubscriptionService.getCurrentBillingSubscriptionItemOrThrow( await this.billingSubscriptionService.getBaseProductCurrentBillingSubscriptionItemOrThrow(
data.workspaceId, data.workspaceId,
); );
await this.stripeSubscriptionItemService.updateSubscriptionItem( await this.stripeSubscriptionItemService.updateSubscriptionItem(
billingSubscriptionItem.stripeSubscriptionItemId, billingBaseProductSubscriptionItem.stripeSubscriptionItemId,
workspaceMembersCount, workspaceMembersCount,
); );

View File

@ -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<BillingProduct[]> {
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];
}
}

View File

@ -11,6 +11,8 @@ import {
BillingExceptionCode, BillingExceptionCode,
} from 'src/engine/core-modules/billing/billing.exception'; } from 'src/engine/core-modules/billing/billing.exception';
import { BillingEntitlement } from 'src/engine/core-modules/billing/entities/billing-entitlement.entity'; 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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'; 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 billingPlanService: BillingPlanService,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
private readonly featureFlagService: FeatureFlagService, private readonly featureFlagService: FeatureFlagService,
private readonly billingProductService: BillingProductService,
@InjectRepository(BillingEntitlement, 'core') @InjectRepository(BillingEntitlement, 'core')
private readonly billingEntitlementRepository: Repository<BillingEntitlement>, private readonly billingEntitlementRepository: Repository<BillingEntitlement>,
@InjectRepository(BillingSubscription, 'core') @InjectRepository(BillingSubscription, 'core')
@ -59,9 +63,9 @@ export class BillingSubscriptionService {
return notCanceledSubscriptions?.[0]; return notCanceledSubscriptions?.[0];
} }
async getCurrentBillingSubscriptionItemOrThrow( async getBaseProductCurrentBillingSubscriptionItemOrThrow(
workspaceId: string, workspaceId: string,
stripeProductId = this.environmentService.get( stripeBaseProductId = this.environmentService.get(
'BILLING_STRIPE_BASE_PLAN_PRODUCT_ID', 'BILLING_STRIPE_BASE_PLAN_PRODUCT_ID',
), ),
) { ) {
@ -78,7 +82,7 @@ export class BillingSubscriptionService {
const getStripeProductId = isBillingPlansEnabled const getStripeProductId = isBillingPlansEnabled
? (await this.billingPlanService.getPlanBaseProduct(BillingPlanKey.PRO)) ? (await this.billingPlanService.getPlanBaseProduct(BillingPlanKey.PRO))
?.stripeProductId ?.stripeProductId
: stripeProductId; : stripeBaseProductId;
if (!getStripeProductId) { if (!getStripeProductId) {
throw new BillingException( throw new BillingException(
@ -164,42 +168,71 @@ export class BillingSubscriptionService {
? SubscriptionInterval.Month ? SubscriptionInterval.Month
: SubscriptionInterval.Year; : SubscriptionInterval.Year;
const billingSubscriptionItem = const billingBaseProductSubscriptionItem =
await this.getCurrentBillingSubscriptionItemOrThrow(workspace.id); await this.getBaseProductCurrentBillingSubscriptionItemOrThrow(
workspace.id,
let productPrice; );
if (isBillingPlansEnabled) { if (isBillingPlansEnabled) {
const baseProduct = await this.billingPlanService.getPlanBaseProduct( const billingProductsByPlan =
BillingPlanKey.PRO, await this.billingProductService.getProductsByPlan(BillingPlanKey.PRO);
const pricesPerPlanArray =
this.billingProductService.getProductPricesByInterval({
interval: newInterval,
billingProductsByPlan,
});
const subscriptionItemsToUpdate = this.getSubscriptionItemsToUpdate(
billingSubscription,
pricesPerPlanArray,
); );
if (!baseProduct) { await this.stripeSubscriptionService.updateSubscriptionItems(
throw new BillingException( billingSubscription.stripeSubscriptionId,
'Base product not found', subscriptionItemsToUpdate,
BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND,
);
}
productPrice = baseProduct.billingPrices.find(
(price) => price.interval === newInterval,
); );
} else { } else {
productPrice = await this.stripePriceService.getStripePrice( const productPrice = await this.stripePriceService.getStripePrice(
AvailableProduct.BasePlan, AvailableProduct.BasePlan,
newInterval, newInterval,
); );
}
if (!productPrice) { if (!productPrice) {
throw new Error( throw new Error(
`Cannot find product price for product ${AvailableProduct.BasePlan} and interval ${newInterval}`, `Cannot find product price for product ${AvailableProduct.BasePlan} and interval ${newInterval}`,
);
}
await this.stripeSubscriptionItemService.updateBillingSubscriptionItem(
billingBaseProductSubscriptionItem,
productPrice.stripePriceId,
); );
} }
}
await this.stripeSubscriptionItemService.updateBillingSubscriptionItem( private getSubscriptionItemsToUpdate(
billingSubscriptionItem, billingSubscription: BillingSubscription,
productPrice.stripePriceId, 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;
} }
} }

View File

@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import Stripe from 'stripe'; 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 { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.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); 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,
});
}
} }