add metered products usage (#11452)
- add metered products usage module on settings/billing page - add new resolver + logic with meter event data fetching from Stripe <img width="590" alt="Screenshot 2025-04-08 at 16 34 07" src="https://github.com/user-attachments/assets/34327af1-3482-4d61-91a6-e2dbaeb017ab" /> <img width="570" alt="Screenshot 2025-04-08 at 16 31 58" src="https://github.com/user-attachments/assets/55aa221a-925f-48bf-88c4-f20713c79962" /> - bonus : disable subscription switch from yearly to monthly closes https://github.com/twentyhq/core-team-issues/issues/681
This commit is contained in:
@ -13,6 +13,7 @@ export enum BillingExceptionCode {
|
||||
BILLING_PLAN_NOT_FOUND = 'BILLING_PLAN_NOT_FOUND',
|
||||
BILLING_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND',
|
||||
BILLING_PRICE_NOT_FOUND = 'BILLING_PRICE_NOT_FOUND',
|
||||
BILLING_METER_NOT_FOUND = 'BILLING_METER_NOT_FOUND',
|
||||
BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND',
|
||||
BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND = 'BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND',
|
||||
BILLING_METER_EVENT_FAILED = 'BILLING_METER_EVENT_FAILED',
|
||||
@ -20,4 +21,5 @@ export enum BillingExceptionCode {
|
||||
BILLING_UNHANDLED_ERROR = 'BILLING_UNHANDLED_ERROR',
|
||||
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',
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ import { BillingWorkspaceMemberListener } from 'src/engine/core-modules/billing/
|
||||
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 { BillingSubscriptionItemService } from 'src/engine/core-modules/billing/services/billing-subscription-item.service';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
@ -64,6 +65,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
controllers: [BillingController],
|
||||
providers: [
|
||||
BillingSubscriptionService,
|
||||
BillingSubscriptionItemService,
|
||||
BillingWebhookSubscriptionService,
|
||||
BillingWebhookEntitlementService,
|
||||
BillingPortalWorkspaceService,
|
||||
|
||||
@ -8,6 +8,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { BillingCheckoutSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-checkout-session.input';
|
||||
import { BillingSessionInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-session.input';
|
||||
import { BillingEndTrialPeriodOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-end-trial-period.output';
|
||||
import { BillingMeteredProductUsageOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output';
|
||||
import { BillingPlanOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-plan.output';
|
||||
import { BillingSessionOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-session.output';
|
||||
import { BillingUpdateOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-update.output';
|
||||
@ -15,10 +16,10 @@ import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-pl
|
||||
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 { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
|
||||
import { BillingPortalCheckoutSessionParameters } from 'src/engine/core-modules/billing/types/billing-portal-checkout-session-parameters.type';
|
||||
import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
|
||||
@ -44,8 +45,8 @@ export class BillingResolver {
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
private readonly billingPortalWorkspaceService: BillingPortalWorkspaceService,
|
||||
private readonly billingPlanService: BillingPlanService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly billingService: BillingService,
|
||||
private readonly billingUsageService: BillingUsageService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
) {}
|
||||
|
||||
@ -117,8 +118,8 @@ export class BillingResolver {
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionsGuard(SettingPermissionType.WORKSPACE),
|
||||
)
|
||||
async updateBillingSubscription(@AuthWorkspace() workspace: Workspace) {
|
||||
await this.billingSubscriptionService.applyBillingSubscription(workspace);
|
||||
async switchToYearlyInterval(@AuthWorkspace() workspace: Workspace) {
|
||||
await this.billingSubscriptionService.switchToYearlyInterval(workspace);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@ -142,6 +143,17 @@ export class BillingResolver {
|
||||
return await this.billingSubscriptionService.endTrialPeriod(workspace);
|
||||
}
|
||||
|
||||
@Query(() => [BillingMeteredProductUsageOutput])
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionsGuard(SettingPermissionType.WORKSPACE),
|
||||
)
|
||||
async getMeteredProductsUsage(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<BillingMeteredProductUsageOutput[]> {
|
||||
return await this.billingUsageService.getMeteredProductsUsage(workspace);
|
||||
}
|
||||
|
||||
private async validateCanCheckoutSessionPermissionOrThrow({
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
|
||||
|
||||
@ObjectType()
|
||||
export class BillingMeteredProductUsageOutput {
|
||||
@Field(() => BillingProductKey)
|
||||
productKey: BillingProductKey;
|
||||
|
||||
@Field(() => Date)
|
||||
periodStart: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
periodEnd: Date;
|
||||
|
||||
@Field(() => Number)
|
||||
usageQuantity: number;
|
||||
|
||||
@Field(() => Number)
|
||||
includedFreeQuantity: number;
|
||||
|
||||
@Field(() => Number)
|
||||
unitPriceCents: number;
|
||||
|
||||
@Field(() => Number)
|
||||
totalCostCents: number;
|
||||
}
|
||||
@ -42,6 +42,7 @@ export class BillingRestApiExceptionFilter implements ExceptionFilter {
|
||||
case BillingExceptionCode.BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND:
|
||||
case BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND:
|
||||
case BillingExceptionCode.BILLING_PLAN_NOT_FOUND:
|
||||
case BillingExceptionCode.BILLING_METER_NOT_FOUND:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception,
|
||||
response,
|
||||
@ -49,6 +50,7 @@ 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:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception,
|
||||
response,
|
||||
|
||||
@ -0,0 +1,82 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { JsonContains, Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
BillingException,
|
||||
BillingExceptionCode,
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
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 { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||
|
||||
@Injectable()
|
||||
export class BillingSubscriptionItemService {
|
||||
constructor(
|
||||
@InjectRepository(BillingSubscriptionItem, 'core')
|
||||
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
|
||||
) {}
|
||||
|
||||
async getMeteredSubscriptionItemDetails(subscriptionId: string) {
|
||||
const meteredSubscriptionItems =
|
||||
await this.billingSubscriptionItemRepository.find({
|
||||
where: {
|
||||
billingSubscriptionId: subscriptionId,
|
||||
billingProduct: {
|
||||
metadata: JsonContains({
|
||||
priceUsageBased: BillingUsageType.METERED,
|
||||
}),
|
||||
},
|
||||
},
|
||||
relations: ['billingProduct', 'billingProduct.billingPrices'],
|
||||
});
|
||||
|
||||
return meteredSubscriptionItems.map((item) => {
|
||||
const price = this.findMatchingPrice(item);
|
||||
|
||||
const stripeMeterId = price.stripeMeterId;
|
||||
|
||||
if (!stripeMeterId) {
|
||||
throw new BillingException(
|
||||
`Stripe meter ID not found for product ${item.billingProduct.metadata.productKey}`,
|
||||
BillingExceptionCode.BILLING_METER_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
stripeSubscriptionItemId: item.stripeSubscriptionItemId,
|
||||
productKey: item.billingProduct.metadata.productKey,
|
||||
stripeMeterId,
|
||||
includedFreeQuantity: this.getIncludedFreeQuantity(price),
|
||||
unitPriceCents: this.getUnitPrice(price),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private findMatchingPrice(item: BillingSubscriptionItem): BillingPrice {
|
||||
const matchingPrice = item.billingProduct.billingPrices.find(
|
||||
(price) => price.stripePriceId === item.stripePriceId,
|
||||
);
|
||||
|
||||
if (!matchingPrice) {
|
||||
throw new BillingException(
|
||||
`Cannot find price for product ${item.stripeProductId}`,
|
||||
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return matchingPrice;
|
||||
}
|
||||
|
||||
private getIncludedFreeQuantity(price: BillingPrice): number {
|
||||
return price.tiers?.find((tier) => tier.unit_amount === 0)?.up_to || 0;
|
||||
}
|
||||
|
||||
private getUnitPrice(price: BillingPrice): number {
|
||||
return Number(
|
||||
price.tiers?.find((tier) => tier.up_to === null)?.unit_amount_decimal ||
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -145,14 +145,19 @@ export class BillingSubscriptionService {
|
||||
return entitlement.value;
|
||||
}
|
||||
|
||||
async applyBillingSubscription(workspace: Workspace) {
|
||||
async switchToYearlyInterval(workspace: Workspace) {
|
||||
const billingSubscription = await this.getCurrentBillingSubscriptionOrThrow(
|
||||
{ workspaceId: workspace.id },
|
||||
);
|
||||
const newInterval =
|
||||
billingSubscription?.interval === SubscriptionInterval.Year
|
||||
? SubscriptionInterval.Month
|
||||
: SubscriptionInterval.Year;
|
||||
|
||||
if (billingSubscription.interval === SubscriptionInterval.Year) {
|
||||
throw new BillingException(
|
||||
'Cannot switch from yearly to monthly billing interval',
|
||||
BillingExceptionCode.BILLING_SUBSCRIPTION_INTERVAL_NOT_SWITCHABLE,
|
||||
);
|
||||
}
|
||||
|
||||
const newInterval = SubscriptionInterval.Year;
|
||||
|
||||
const planKey = getPlanKeyFromSubscription(billingSubscription);
|
||||
const billingProductsByPlan =
|
||||
|
||||
@ -3,17 +3,22 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
BillingException,
|
||||
BillingExceptionCode,
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { BillingMeteredProductUsageOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output';
|
||||
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||
import { BillingSubscriptionItemService } from 'src/engine/core-modules/billing/services/billing-subscription-item.service';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { StripeBillingMeterEventService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter-event.service';
|
||||
import { BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Injectable()
|
||||
export class BillingUsageService {
|
||||
@ -24,6 +29,7 @@ export class BillingUsageService {
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
private readonly stripeBillingMeterEventService: StripeBillingMeterEventService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly billingSubscriptionItemService: BillingSubscriptionItemService,
|
||||
) {}
|
||||
|
||||
async canFeatureBeUsed(workspaceId: string): Promise<boolean> {
|
||||
@ -79,4 +85,60 @@ export class BillingUsageService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getMeteredProductsUsage(
|
||||
workspace: Workspace,
|
||||
): Promise<BillingMeteredProductUsageOutput[]> {
|
||||
const subscription =
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
|
||||
{ workspaceId: workspace.id },
|
||||
);
|
||||
|
||||
const meteredSubscriptionItemDetails =
|
||||
await this.billingSubscriptionItemService.getMeteredSubscriptionItemDetails(
|
||||
subscription.id,
|
||||
);
|
||||
|
||||
let periodStart: Date;
|
||||
let periodEnd: Date;
|
||||
|
||||
if (
|
||||
subscription.status === SubscriptionStatus.Trialing &&
|
||||
isDefined(subscription.trialStart) &&
|
||||
isDefined(subscription.trialEnd)
|
||||
) {
|
||||
periodStart = subscription.trialStart;
|
||||
periodEnd = subscription.trialEnd;
|
||||
} else {
|
||||
periodStart = subscription.currentPeriodStart;
|
||||
periodEnd = subscription.currentPeriodEnd;
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
meteredSubscriptionItemDetails.map(async (item) => {
|
||||
const meterEventsSum =
|
||||
await this.stripeBillingMeterEventService.sumMeterEvents(
|
||||
item.stripeMeterId,
|
||||
subscription.stripeCustomerId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
);
|
||||
|
||||
const totalCostCents =
|
||||
meterEventsSum - item.includedFreeQuantity > 0
|
||||
? (meterEventsSum - item.includedFreeQuantity) * item.unitPriceCents
|
||||
: 0;
|
||||
|
||||
return {
|
||||
productKey: item.productKey,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
usageQuantity: meterEventsSum,
|
||||
includedFreeQuantity: item.includedFreeQuantity,
|
||||
unitPriceCents: item.unitPriceCents,
|
||||
totalCostCents,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,4 +42,24 @@ export class StripeBillingMeterEventService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async sumMeterEvents(
|
||||
stripeMeterId: string,
|
||||
stripeCustomerId: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
) {
|
||||
const eventSummaries = await this.stripe.billing.meters.listEventSummaries(
|
||||
stripeMeterId,
|
||||
{
|
||||
customer: stripeCustomerId,
|
||||
start_time: Math.floor(startTime.getTime() / (1000 * 60)) * 60,
|
||||
end_time: Math.ceil(endTime.getTime() / (1000 * 60)) * 60,
|
||||
},
|
||||
);
|
||||
|
||||
return eventSummaries.data.reduce((acc, eventSummary) => {
|
||||
return acc + eventSummary.aggregated_value;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user