add billing threshold + specific trial free credits (#11533)

In this PR : 
- set billing thresholds after subscription creation (not possible
during billing checkout)
- add specific free trial workflow credit quantities + set them in
subscription item + check them when receiving stripe alert event

closes : https://github.com/twentyhq/core-team-issues/issues/682
This commit is contained in:
Etienne
2025-04-14 18:25:07 +02:00
committed by GitHub
parent 704b18af30
commit 9a69cd0b61
17 changed files with 218 additions and 21 deletions

View File

@ -14,6 +14,7 @@ export enum BillingExceptionCode {
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_ITEM_NOT_FOUND = 'BILLING_SUBSCRIPTION_ITEM_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',

View File

@ -17,7 +17,10 @@ export class BillingMeteredProductUsageOutput {
usageQuantity: number;
@Field(() => Number)
includedFreeQuantity: number;
freeTierQuantity: number;
@Field(() => Number)
freeTrialQuantity: number;
@Field(() => Number)
unitPriceCents: number;

View File

@ -15,6 +15,7 @@ import {
import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionItemMetadata } from 'src/engine/core-modules/billing/types/billing-subscription-item-metadata.type';
@Entity({ name: 'billingSubscriptionItem', schema: 'core' })
@Unique('IndexOnBillingSubscriptionIdAndStripeProductIdUnique', [
'billingSubscriptionId',
@ -40,7 +41,7 @@ export class BillingSubscriptionItem {
stripeSubscriptionId: string;
@Column({ nullable: false, type: 'jsonb', default: {} })
metadata: Stripe.Metadata;
metadata: BillingSubscriptionItemMetadata;
@Column({ nullable: true, type: 'jsonb' })
billingThresholds: Stripe.SubscriptionItem.BillingThresholds;

View File

@ -43,6 +43,7 @@ export class BillingRestApiExceptionFilter implements ExceptionFilter {
case BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND:
case BillingExceptionCode.BILLING_PLAN_NOT_FOUND:
case BillingExceptionCode.BILLING_METER_NOT_FOUND:
case BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND:
return this.httpExceptionHandlerService.handleError(
exception,
response,

View File

@ -45,7 +45,7 @@ export class UpdateSubscriptionQuantityJob {
await this.stripeSubscriptionItemService.updateSubscriptionItem(
billingBaseProductSubscriptionItem.stripeSubscriptionItemId,
workspaceMembersCount,
{ quantity: workspaceMembersCount },
);
this.logger.log(

View File

@ -9,13 +9,16 @@ import {
} 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 { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class BillingSubscriptionItemService {
constructor(
@InjectRepository(BillingSubscriptionItem, 'core')
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
private readonly twentyConfigService: TwentyConfigService,
) {}
async getMeteredSubscriptionItemDetails(subscriptionId: string) {
@ -48,7 +51,8 @@ export class BillingSubscriptionItemService {
stripeSubscriptionItemId: item.stripeSubscriptionItemId,
productKey: item.billingProduct.metadata.productKey,
stripeMeterId,
includedFreeQuantity: this.getIncludedFreeQuantity(price),
freeTierQuantity: this.getFreeTierQuantity(price),
freeTrialQuantity: this.getFreeTrialQuantity(item),
unitPriceCents: this.getUnitPrice(price),
};
});
@ -69,10 +73,24 @@ export class BillingSubscriptionItemService {
return matchingPrice;
}
private getIncludedFreeQuantity(price: BillingPrice): number {
private getFreeTierQuantity(price: BillingPrice): number {
return price.tiers?.find((tier) => tier.unit_amount === 0)?.up_to || 0;
}
private getFreeTrialQuantity(item: BillingSubscriptionItem): number {
switch (item.billingProduct.metadata.productKey) {
case BillingProductKey.WORKFLOW_NODE_EXECUTION:
return (
item.metadata.trialPeriodFreeWorkflowCredits ||
this.twentyConfigService.get(
'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITHOUT_CREDIT_CARD',
)
);
default:
return 0;
}
}
private getUnitPrice(price: BillingPrice): number {
return Number(
price.tiers?.find((tier) => tier.up_to === null)?.unit_amount_decimal ||

View File

@ -5,7 +5,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import assert from 'assert';
import { differenceInDays } from 'date-fns';
import Stripe from 'stripe';
import { isDefined } from 'twenty-shared/utils';
import { Not, Repository } from 'typeorm';
import {
@ -17,14 +19,17 @@ import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-p
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 { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.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 { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
import { BillingProductService } from 'src/engine/core-modules/billing/services/billing-product.service';
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.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 { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/utils/get-plan-key-from-subscription.util';
import { getSubscriptionStatus } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class BillingSubscriptionService {
@ -38,6 +43,8 @@ export class BillingSubscriptionService {
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
private readonly stripeCustomerService: StripeCustomerService,
private readonly twentyConfigService: TwentyConfigService,
private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
) {}
async getCurrentBillingSubscriptionOrThrow(criteria: {
@ -238,4 +245,78 @@ export class BillingSubscriptionService {
hasPaymentMethod: true,
};
}
async setBillingThresholdsAndTrialPeriodWorkflowCredits(
billingSubscriptionId: string,
) {
const billingSubscription =
await this.billingSubscriptionRepository.findOneOrFail({
where: { id: billingSubscriptionId },
relations: [
'billingSubscriptionItems',
'billingSubscriptionItems.billingProduct',
],
});
await this.stripeSubscriptionService.updateSubscription(
billingSubscription.stripeSubscriptionId,
{
billing_thresholds: {
amount_gte: this.twentyConfigService.get(
'BILLING_SUBSCRIPTION_THRESHOLD_AMOUNT',
),
reset_billing_cycle_anchor: false,
},
},
);
const workflowSubscriptionItem =
billingSubscription.billingSubscriptionItems.find(
(item) =>
item.billingProduct.metadata.productKey ===
BillingProductKey.WORKFLOW_NODE_EXECUTION,
);
if (!workflowSubscriptionItem) {
throw new BillingException(
'Workflow subscription item not found',
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
);
}
await this.stripeSubscriptionItemService.updateSubscriptionItem(
workflowSubscriptionItem.stripeSubscriptionItemId,
{
metadata: {
trialPeriodFreeWorkflowCredits:
this.getTrialPeriodFreeWorkflowCredits(billingSubscription),
},
},
);
}
private getTrialPeriodFreeWorkflowCredits(
billingSubscription: BillingSubscription,
) {
const trialDuration =
isDefined(billingSubscription.trialEnd) &&
isDefined(billingSubscription.trialStart)
? differenceInDays(
billingSubscription.trialEnd,
billingSubscription.trialStart,
)
: 0;
const trialWithCreditCardDuration = this.twentyConfigService.get(
'BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS',
);
return Number(
this.twentyConfigService.get(
trialDuration === trialWithCreditCardDuration
? 'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITH_CREDIT_CARD'
: 'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITHOUT_CREDIT_CARD',
),
);
}
}

View File

@ -125,8 +125,8 @@ export class BillingUsageService {
);
const totalCostCents =
meterEventsSum - item.includedFreeQuantity > 0
? (meterEventsSum - item.includedFreeQuantity) * item.unitPriceCents
meterEventsSum - item.freeTierQuantity > 0
? (meterEventsSum - item.freeTierQuantity) * item.unitPriceCents
: 0;
return {
@ -134,7 +134,8 @@ export class BillingUsageService {
periodStart,
periodEnd,
usageQuantity: meterEventsSum,
includedFreeQuantity: item.includedFreeQuantity,
freeTierQuantity: item.freeTierQuantity,
freeTrialQuantity: item.freeTrialQuantity,
unitPriceCents: item.unitPriceCents,
totalCostCents,
};

View File

@ -24,8 +24,11 @@ export class StripeSubscriptionItemService {
);
}
async updateSubscriptionItem(stripeItemId: string, quantity: number) {
await this.stripe.subscriptionItems.update(stripeItemId, { quantity });
async updateSubscriptionItem(
stripeItemId: string,
updateData: Stripe.SubscriptionItemUpdateParams,
) {
await this.stripe.subscriptionItems.update(stripeItemId, updateData);
}
async createSubscriptionItem(

View File

@ -6,7 +6,7 @@ import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-pl
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
@ObjectType('BillingProductMetadata')
@ObjectType()
export class BillingProductMetadata {
@Field(() => BillingPlanKey)
planKey: BillingPlanKey;

View File

@ -0,0 +1,7 @@
/* @license Enterprise */
export type BillingSubscriptionItemMetadata =
| {
trialPeriodFreeWorkflowCredits: number;
}
| Record<string, never>;

View File

@ -38,7 +38,10 @@ export class BillingWebhookAlertService {
if (alert.title === TRIAL_PERIOD_ALERT_TITLE && isDefined(stripeMeterId)) {
const subscription = await this.billingSubscriptionRepository.findOne({
where: { stripeCustomerId, status: SubscriptionStatus.Trialing },
relations: ['billingSubscriptionItems'],
relations: [
'billingSubscriptionItems',
'billingSubscriptionItems.billingProduct',
],
});
if (!subscription) return;
@ -56,6 +59,24 @@ export class BillingWebhookAlertService {
);
}
const subscriptionItem = subscription.billingSubscriptionItems.find(
(item) =>
item.billingProduct.stripeProductId === product.stripeProductId,
);
const trialPeriodFreeWorkflowCredits = isDefined(
subscriptionItem?.metadata.trialPeriodFreeWorkflowCredits,
)
? Number(subscriptionItem?.metadata.trialPeriodFreeWorkflowCredits)
: 0;
if (
!isDefined(alert.usage_threshold?.gte) ||
trialPeriodFreeWorkflowCredits !== alert.usage_threshold.gte
) {
return;
}
await this.billingSubscriptionItemRepository.update(
{
billingSubscriptionId: subscription.id,

View File

@ -13,10 +13,12 @@ import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entitie
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
import { BillingWebhookEvent } from 'src/engine/core-modules/billing/enums/billing-webhook-events.enum';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/services/stripe-customer.service';
import { transformStripeSubscriptionEventToDatabaseCustomer } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-customer.util';
import { transformStripeSubscriptionEventToDatabaseSubscriptionItem } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription-item.util';
import { transformStripeSubscriptionEventToDatabaseSubscription } from 'src/engine/core-modules/billing/webhooks/utils/transform-stripe-subscription-event-to-database-subscription.util';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@ -44,6 +46,7 @@ export class BillingWebhookSubscriptionService {
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(BillingCustomer, 'core')
private readonly billingCustomerRepository: Repository<BillingCustomer>,
private readonly billingSubscriptionService: BillingSubscriptionService,
@InjectRepository(FeatureFlag, 'core')
private readonly featureFlagRepository: Repository<FeatureFlag>,
) {}
@ -137,6 +140,24 @@ export class BillingWebhookSubscriptionService {
workspaceId,
);
const isMeteredProductBillingEnabled =
await this.featureFlagRepository.findOne({
where: {
key: FeatureFlagKey.IsMeteredProductBillingEnabled,
workspaceId: workspaceId,
value: true,
},
});
if (
event.type === BillingWebhookEvent.CUSTOMER_SUBSCRIPTION_CREATED &&
isDefined(isMeteredProductBillingEnabled)
) {
await this.billingSubscriptionService.setBillingThresholdsAndTrialPeriodWorkflowCredits(
updatedBillingSubscription.id,
);
}
return {
stripeSubscriptionId: data.object.id,
stripeCustomerId: data.object.customer,

View File

@ -509,6 +509,33 @@ export class ConfigVariables {
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS = 7;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig,
description: 'Amount of money in cents to trigger a billing threshold',
})
@IsNumber()
@CastToPositiveNumber()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_SUBSCRIPTION_THRESHOLD_AMOUNT = 10000;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig,
description: 'Amount of credits for the free trial without credit card',
})
@IsNumber()
@CastToPositiveNumber()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITHOUT_CREDIT_CARD = 5000;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig,
description: 'Amount of credits for the free trial with credit card',
})
@IsNumber()
@CastToPositiveNumber()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITH_CREDIT_CARD = 10000;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig,
isSensitive: true,