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:
Etienne
2025-04-09 11:26:49 +02:00
committed by GitHub
parent b25ee28c12
commit 11fb8e0284
23 changed files with 570 additions and 139 deletions

View File

@ -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',
}

View File

@ -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,

View File

@ -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,

View File

@ -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;
}

View File

@ -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,

View File

@ -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,
);
}
}

View File

@ -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 =

View File

@ -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,
};
}),
);
}
}

View File

@ -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);
}
}