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:
@ -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',
|
||||
|
||||
@ -17,7 +17,10 @@ export class BillingMeteredProductUsageOutput {
|
||||
usageQuantity: number;
|
||||
|
||||
@Field(() => Number)
|
||||
includedFreeQuantity: number;
|
||||
freeTierQuantity: number;
|
||||
|
||||
@Field(() => Number)
|
||||
freeTrialQuantity: number;
|
||||
|
||||
@Field(() => Number)
|
||||
unitPriceCents: number;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -45,7 +45,7 @@ export class UpdateSubscriptionQuantityJob {
|
||||
|
||||
await this.stripeSubscriptionItemService.updateSubscriptionItem(
|
||||
billingBaseProductSubscriptionItem.stripeSubscriptionItemId,
|
||||
workspaceMembersCount,
|
||||
{ quantity: workspaceMembersCount },
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
|
||||
@ -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 ||
|
||||
|
||||
@ -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',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
export type BillingSubscriptionItemMetadata =
|
||||
| {
|
||||
trialPeriodFreeWorkflowCredits: number;
|
||||
}
|
||||
| Record<string, never>;
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user