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:
@ -147,7 +147,8 @@ export type BillingEndTrialPeriodOutput = {
|
|||||||
|
|
||||||
export type BillingMeteredProductUsageOutput = {
|
export type BillingMeteredProductUsageOutput = {
|
||||||
__typename?: 'BillingMeteredProductUsageOutput';
|
__typename?: 'BillingMeteredProductUsageOutput';
|
||||||
includedFreeQuantity: Scalars['Float'];
|
freeTierQuantity: Scalars['Float'];
|
||||||
|
freeTrialQuantity: Scalars['Float'];
|
||||||
periodEnd: Scalars['DateTime'];
|
periodEnd: Scalars['DateTime'];
|
||||||
periodStart: Scalars['DateTime'];
|
periodStart: Scalars['DateTime'];
|
||||||
productKey: BillingProductKey;
|
productKey: BillingProductKey;
|
||||||
@ -2587,7 +2588,7 @@ export type EndSubscriptionTrialPeriodMutation = { __typename?: 'Mutation', endS
|
|||||||
export type GetMeteredProductsUsageQueryVariables = Exact<{ [key: string]: never; }>;
|
export type GetMeteredProductsUsageQueryVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
export type GetMeteredProductsUsageQuery = { __typename?: 'Query', getMeteredProductsUsage: Array<{ __typename?: 'BillingMeteredProductUsageOutput', productKey: BillingProductKey, usageQuantity: number, includedFreeQuantity: number, unitPriceCents: number, totalCostCents: number }> };
|
export type GetMeteredProductsUsageQuery = { __typename?: 'Query', getMeteredProductsUsage: Array<{ __typename?: 'BillingMeteredProductUsageOutput', productKey: BillingProductKey, usageQuantity: number, freeTierQuantity: number, freeTrialQuantity: number, unitPriceCents: number, totalCostCents: number }> };
|
||||||
|
|
||||||
export type SwitchSubscriptionToYearlyIntervalMutationVariables = Exact<{ [key: string]: never; }>;
|
export type SwitchSubscriptionToYearlyIntervalMutationVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
@ -4252,7 +4253,8 @@ export const GetMeteredProductsUsageDocument = gql`
|
|||||||
getMeteredProductsUsage {
|
getMeteredProductsUsage {
|
||||||
productKey
|
productKey
|
||||||
usageQuantity
|
usageQuantity
|
||||||
includedFreeQuantity
|
freeTierQuantity
|
||||||
|
freeTrialQuantity
|
||||||
unitPriceCents
|
unitPriceCents
|
||||||
totalCostCents
|
totalCostCents
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,8 @@ export const GET_METERED_PRODUCTS_USAGE = gql`
|
|||||||
getMeteredProductsUsage {
|
getMeteredProductsUsage {
|
||||||
productKey
|
productKey
|
||||||
usageQuantity
|
usageQuantity
|
||||||
includedFreeQuantity
|
freeTierQuantity
|
||||||
|
freeTrialQuantity
|
||||||
unitPriceCents
|
unitPriceCents
|
||||||
totalCostCents
|
totalCostCents
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
|
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
|
||||||
import {
|
import {
|
||||||
BillingProductKey,
|
BillingProductKey,
|
||||||
|
SubscriptionStatus,
|
||||||
useGetMeteredProductsUsageQuery,
|
useGetMeteredProductsUsageQuery,
|
||||||
} from '~/generated/graphql';
|
} from '~/generated/graphql';
|
||||||
|
|
||||||
export const useGetWorkflowNodeExecutionUsage = () => {
|
export const useGetWorkflowNodeExecutionUsage = () => {
|
||||||
|
const subscriptionStatus = useSubscriptionStatus();
|
||||||
|
|
||||||
const { data, loading } = useGetMeteredProductsUsageQuery();
|
const { data, loading } = useGetMeteredProductsUsageQuery();
|
||||||
|
|
||||||
const workflowUsage = data?.getMeteredProductsUsage.find(
|
const workflowUsage = data?.getMeteredProductsUsage.find(
|
||||||
@ -22,16 +26,21 @@ export const useGetWorkflowNodeExecutionUsage = () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const includedFreeQuantity =
|
||||||
|
subscriptionStatus === SubscriptionStatus.Trialing
|
||||||
|
? workflowUsage.freeTrialQuantity
|
||||||
|
: workflowUsage.freeTierQuantity;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
usageQuantity: workflowUsage.usageQuantity,
|
usageQuantity: workflowUsage.usageQuantity,
|
||||||
freeUsageQuantity:
|
freeUsageQuantity:
|
||||||
workflowUsage.usageQuantity > workflowUsage.includedFreeQuantity
|
workflowUsage.usageQuantity > includedFreeQuantity
|
||||||
? workflowUsage.includedFreeQuantity
|
? includedFreeQuantity
|
||||||
: workflowUsage.usageQuantity,
|
: workflowUsage.usageQuantity,
|
||||||
includedFreeQuantity: workflowUsage.includedFreeQuantity,
|
includedFreeQuantity,
|
||||||
paidUsageQuantity:
|
paidUsageQuantity:
|
||||||
workflowUsage.usageQuantity > workflowUsage.includedFreeQuantity
|
workflowUsage.usageQuantity > includedFreeQuantity
|
||||||
? workflowUsage.usageQuantity - workflowUsage.includedFreeQuantity
|
? workflowUsage.usageQuantity - includedFreeQuantity
|
||||||
: 0,
|
: 0,
|
||||||
unitPriceCents: workflowUsage.unitPriceCents,
|
unitPriceCents: workflowUsage.unitPriceCents,
|
||||||
totalCostCents: workflowUsage.totalCostCents,
|
totalCostCents: workflowUsage.totalCostCents,
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export enum BillingExceptionCode {
|
|||||||
BILLING_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND',
|
BILLING_PRODUCT_NOT_FOUND = 'BILLING_PRODUCT_NOT_FOUND',
|
||||||
BILLING_PRICE_NOT_FOUND = 'BILLING_PRICE_NOT_FOUND',
|
BILLING_PRICE_NOT_FOUND = 'BILLING_PRICE_NOT_FOUND',
|
||||||
BILLING_METER_NOT_FOUND = 'BILLING_METER_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_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND',
|
||||||
BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND = 'BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND',
|
BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND = 'BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND',
|
||||||
BILLING_METER_EVENT_FAILED = 'BILLING_METER_EVENT_FAILED',
|
BILLING_METER_EVENT_FAILED = 'BILLING_METER_EVENT_FAILED',
|
||||||
|
|||||||
@ -17,7 +17,10 @@ export class BillingMeteredProductUsageOutput {
|
|||||||
usageQuantity: number;
|
usageQuantity: number;
|
||||||
|
|
||||||
@Field(() => Number)
|
@Field(() => Number)
|
||||||
includedFreeQuantity: number;
|
freeTierQuantity: number;
|
||||||
|
|
||||||
|
@Field(() => Number)
|
||||||
|
freeTrialQuantity: number;
|
||||||
|
|
||||||
@Field(() => Number)
|
@Field(() => Number)
|
||||||
unitPriceCents: number;
|
unitPriceCents: number;
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
|
|
||||||
import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity';
|
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 { 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' })
|
@Entity({ name: 'billingSubscriptionItem', schema: 'core' })
|
||||||
@Unique('IndexOnBillingSubscriptionIdAndStripeProductIdUnique', [
|
@Unique('IndexOnBillingSubscriptionIdAndStripeProductIdUnique', [
|
||||||
'billingSubscriptionId',
|
'billingSubscriptionId',
|
||||||
@ -40,7 +41,7 @@ export class BillingSubscriptionItem {
|
|||||||
stripeSubscriptionId: string;
|
stripeSubscriptionId: string;
|
||||||
|
|
||||||
@Column({ nullable: false, type: 'jsonb', default: {} })
|
@Column({ nullable: false, type: 'jsonb', default: {} })
|
||||||
metadata: Stripe.Metadata;
|
metadata: BillingSubscriptionItemMetadata;
|
||||||
|
|
||||||
@Column({ nullable: true, type: 'jsonb' })
|
@Column({ nullable: true, type: 'jsonb' })
|
||||||
billingThresholds: Stripe.SubscriptionItem.BillingThresholds;
|
billingThresholds: Stripe.SubscriptionItem.BillingThresholds;
|
||||||
|
|||||||
@ -43,6 +43,7 @@ export class BillingRestApiExceptionFilter implements ExceptionFilter {
|
|||||||
case BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND:
|
case BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND:
|
||||||
case BillingExceptionCode.BILLING_PLAN_NOT_FOUND:
|
case BillingExceptionCode.BILLING_PLAN_NOT_FOUND:
|
||||||
case BillingExceptionCode.BILLING_METER_NOT_FOUND:
|
case BillingExceptionCode.BILLING_METER_NOT_FOUND:
|
||||||
|
case BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND:
|
||||||
return this.httpExceptionHandlerService.handleError(
|
return this.httpExceptionHandlerService.handleError(
|
||||||
exception,
|
exception,
|
||||||
response,
|
response,
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export class UpdateSubscriptionQuantityJob {
|
|||||||
|
|
||||||
await this.stripeSubscriptionItemService.updateSubscriptionItem(
|
await this.stripeSubscriptionItemService.updateSubscriptionItem(
|
||||||
billingBaseProductSubscriptionItem.stripeSubscriptionItemId,
|
billingBaseProductSubscriptionItem.stripeSubscriptionItemId,
|
||||||
workspaceMembersCount,
|
{ quantity: workspaceMembersCount },
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
|
|||||||
@ -9,13 +9,16 @@ import {
|
|||||||
} from 'src/engine/core-modules/billing/billing.exception';
|
} from 'src/engine/core-modules/billing/billing.exception';
|
||||||
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.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 { 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 { 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()
|
@Injectable()
|
||||||
export class BillingSubscriptionItemService {
|
export class BillingSubscriptionItemService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(BillingSubscriptionItem, 'core')
|
@InjectRepository(BillingSubscriptionItem, 'core')
|
||||||
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
|
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
|
||||||
|
private readonly twentyConfigService: TwentyConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getMeteredSubscriptionItemDetails(subscriptionId: string) {
|
async getMeteredSubscriptionItemDetails(subscriptionId: string) {
|
||||||
@ -48,7 +51,8 @@ export class BillingSubscriptionItemService {
|
|||||||
stripeSubscriptionItemId: item.stripeSubscriptionItemId,
|
stripeSubscriptionItemId: item.stripeSubscriptionItemId,
|
||||||
productKey: item.billingProduct.metadata.productKey,
|
productKey: item.billingProduct.metadata.productKey,
|
||||||
stripeMeterId,
|
stripeMeterId,
|
||||||
includedFreeQuantity: this.getIncludedFreeQuantity(price),
|
freeTierQuantity: this.getFreeTierQuantity(price),
|
||||||
|
freeTrialQuantity: this.getFreeTrialQuantity(item),
|
||||||
unitPriceCents: this.getUnitPrice(price),
|
unitPriceCents: this.getUnitPrice(price),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -69,10 +73,24 @@ export class BillingSubscriptionItemService {
|
|||||||
return matchingPrice;
|
return matchingPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getIncludedFreeQuantity(price: BillingPrice): number {
|
private getFreeTierQuantity(price: BillingPrice): number {
|
||||||
return price.tiers?.find((tier) => tier.unit_amount === 0)?.up_to || 0;
|
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 {
|
private getUnitPrice(price: BillingPrice): number {
|
||||||
return Number(
|
return Number(
|
||||||
price.tiers?.find((tier) => tier.up_to === null)?.unit_amount_decimal ||
|
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 assert from 'assert';
|
||||||
|
|
||||||
|
import { differenceInDays } from 'date-fns';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
import { Not, Repository } from 'typeorm';
|
import { Not, Repository } from 'typeorm';
|
||||||
|
|
||||||
import {
|
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 { 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 { 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';
|
||||||
|
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 { 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 { 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 { 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 { 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 { 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 { 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';
|
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillingSubscriptionService {
|
export class BillingSubscriptionService {
|
||||||
@ -38,6 +43,8 @@ export class BillingSubscriptionService {
|
|||||||
@InjectRepository(BillingSubscription, 'core')
|
@InjectRepository(BillingSubscription, 'core')
|
||||||
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
||||||
private readonly stripeCustomerService: StripeCustomerService,
|
private readonly stripeCustomerService: StripeCustomerService,
|
||||||
|
private readonly twentyConfigService: TwentyConfigService,
|
||||||
|
private readonly stripeSubscriptionItemService: StripeSubscriptionItemService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getCurrentBillingSubscriptionOrThrow(criteria: {
|
async getCurrentBillingSubscriptionOrThrow(criteria: {
|
||||||
@ -238,4 +245,78 @@ export class BillingSubscriptionService {
|
|||||||
hasPaymentMethod: true,
|
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 =
|
const totalCostCents =
|
||||||
meterEventsSum - item.includedFreeQuantity > 0
|
meterEventsSum - item.freeTierQuantity > 0
|
||||||
? (meterEventsSum - item.includedFreeQuantity) * item.unitPriceCents
|
? (meterEventsSum - item.freeTierQuantity) * item.unitPriceCents
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -134,7 +134,8 @@ export class BillingUsageService {
|
|||||||
periodStart,
|
periodStart,
|
||||||
periodEnd,
|
periodEnd,
|
||||||
usageQuantity: meterEventsSum,
|
usageQuantity: meterEventsSum,
|
||||||
includedFreeQuantity: item.includedFreeQuantity,
|
freeTierQuantity: item.freeTierQuantity,
|
||||||
|
freeTrialQuantity: item.freeTrialQuantity,
|
||||||
unitPriceCents: item.unitPriceCents,
|
unitPriceCents: item.unitPriceCents,
|
||||||
totalCostCents,
|
totalCostCents,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -24,8 +24,11 @@ export class StripeSubscriptionItemService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSubscriptionItem(stripeItemId: string, quantity: number) {
|
async updateSubscriptionItem(
|
||||||
await this.stripe.subscriptionItems.update(stripeItemId, { quantity });
|
stripeItemId: string,
|
||||||
|
updateData: Stripe.SubscriptionItemUpdateParams,
|
||||||
|
) {
|
||||||
|
await this.stripe.subscriptionItems.update(stripeItemId, updateData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSubscriptionItem(
|
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 { 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 { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||||
|
|
||||||
@ObjectType('BillingProductMetadata')
|
@ObjectType()
|
||||||
export class BillingProductMetadata {
|
export class BillingProductMetadata {
|
||||||
@Field(() => BillingPlanKey)
|
@Field(() => BillingPlanKey)
|
||||||
planKey: 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)) {
|
if (alert.title === TRIAL_PERIOD_ALERT_TITLE && isDefined(stripeMeterId)) {
|
||||||
const subscription = await this.billingSubscriptionRepository.findOne({
|
const subscription = await this.billingSubscriptionRepository.findOne({
|
||||||
where: { stripeCustomerId, status: SubscriptionStatus.Trialing },
|
where: { stripeCustomerId, status: SubscriptionStatus.Trialing },
|
||||||
relations: ['billingSubscriptionItems'],
|
relations: [
|
||||||
|
'billingSubscriptionItems',
|
||||||
|
'billingSubscriptionItems.billingProduct',
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!subscription) return;
|
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(
|
await this.billingSubscriptionItemRepository.update(
|
||||||
{
|
{
|
||||||
billingSubscriptionId: subscription.id,
|
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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||||
@ -44,6 +46,7 @@ export class BillingWebhookSubscriptionService {
|
|||||||
private readonly workspaceRepository: Repository<Workspace>,
|
private readonly workspaceRepository: Repository<Workspace>,
|
||||||
@InjectRepository(BillingCustomer, 'core')
|
@InjectRepository(BillingCustomer, 'core')
|
||||||
private readonly billingCustomerRepository: Repository<BillingCustomer>,
|
private readonly billingCustomerRepository: Repository<BillingCustomer>,
|
||||||
|
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||||
@InjectRepository(FeatureFlag, 'core')
|
@InjectRepository(FeatureFlag, 'core')
|
||||||
private readonly featureFlagRepository: Repository<FeatureFlag>,
|
private readonly featureFlagRepository: Repository<FeatureFlag>,
|
||||||
) {}
|
) {}
|
||||||
@ -137,6 +140,24 @@ export class BillingWebhookSubscriptionService {
|
|||||||
workspaceId,
|
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 {
|
return {
|
||||||
stripeSubscriptionId: data.object.id,
|
stripeSubscriptionId: data.object.id,
|
||||||
stripeCustomerId: data.object.customer,
|
stripeCustomerId: data.object.customer,
|
||||||
|
|||||||
@ -509,6 +509,33 @@ export class ConfigVariables {
|
|||||||
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
|
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
|
||||||
BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS = 7;
|
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({
|
@ConfigVariablesMetadata({
|
||||||
group: ConfigVariablesGroup.BillingConfig,
|
group: ConfigVariablesGroup.BillingConfig,
|
||||||
isSensitive: true,
|
isSensitive: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user