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:
committed by
GitHub
parent
c89cc38729
commit
f58f84114c
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user