feat(billing): refacto billing (#14243)
… prices for metered billing --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@@ -12,6 +12,7 @@ export enum BillingExceptionCode {
|
||||
BILLING_METER_NOT_FOUND = 'BILLING_METER_NOT_FOUND',
|
||||
BILLING_SUBSCRIPTION_NOT_FOUND = 'BILLING_SUBSCRIPTION_NOT_FOUND',
|
||||
BILLING_SUBSCRIPTION_ITEM_NOT_FOUND = 'BILLING_SUBSCRIPTION_ITEM_NOT_FOUND',
|
||||
BILLING_SUBSCRIPTION_INVALID = 'BILLING_SUBSCRIPTION_INVALID',
|
||||
BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_SUBSCRIPTION_EVENT_WORKSPACE_NOT_FOUND',
|
||||
BILLING_CUSTOMER_EVENT_WORKSPACE_NOT_FOUND = 'BILLING_CUSTOMER_EVENT_WORKSPACE_NOT_FOUND',
|
||||
BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND = 'BILLING_ACTIVE_SUBSCRIPTION_NOT_FOUND',
|
||||
@@ -21,7 +22,10 @@ export enum BillingExceptionCode {
|
||||
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',
|
||||
BILLING_SUBSCRIPTION_INTERVAL_INVALID = 'BILLING_SUBSCRIPTION_INTERVAL_INVALID',
|
||||
BILLING_SUBSCRIPTION_PLAN_NOT_SWITCHABLE = 'BILLING_SUBSCRIPTION_PLAN_NOT_SWITCHABLE',
|
||||
BILLING_SUBSCRIPTION_ITEM_INVALID = 'BILLING_SUBSCRIPTION_ITEM_INVALID',
|
||||
BILLING_PRICE_INVALID_TIERS = 'BILLING_PRICE_INVALID_TIERS',
|
||||
BILLING_PRICE_UPDATE_REQUIRES_INCREASE = 'BILLING_PRICE_UPDATE_REQUIRES_INCREASE',
|
||||
BILLING_PRICE_INVALID = 'BILLING_PRICE_INVALID',
|
||||
BILLING_SUBSCRIPTION_PHASE_NOT_FOUND = 'BILLING_SUBSCRIPTION_PHASE_NOT_FOUND',
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { MessageQueueModule } from 'src/engine/core-modules/message-queue/messag
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { BillingSubscriptionPhaseService } from 'src/engine/core-modules/billing/services/billing-subscription-phase.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -61,6 +62,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
BillingSubscriptionItemService,
|
||||
BillingPortalWorkspaceService,
|
||||
BillingProductService,
|
||||
BillingSubscriptionPhaseService,
|
||||
BillingResolver,
|
||||
BillingPlanService,
|
||||
BillingWorkspaceMemberListener,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
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';
|
||||
@@ -19,7 +18,6 @@ import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/s
|
||||
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 { type 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 { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
@@ -40,7 +38,6 @@ import {
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
|
||||
import { BillingPriceOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-price.output';
|
||||
import { BillingUpdateSubscriptionItemPriceInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-update-subscription-item-price.input';
|
||||
|
||||
@Resolver()
|
||||
@@ -98,7 +95,7 @@ export class BillingResolver {
|
||||
workspaceActivationStatus: workspace.activationStatus,
|
||||
});
|
||||
|
||||
const checkoutSessionParams: BillingPortalCheckoutSessionParameters = {
|
||||
const checkoutSessionParams = {
|
||||
user,
|
||||
workspace,
|
||||
successUrlPath,
|
||||
@@ -106,12 +103,11 @@ export class BillingResolver {
|
||||
requirePaymentMethod,
|
||||
};
|
||||
|
||||
const billingPricesPerPlan = await this.billingPlanService.getPricesPerPlan(
|
||||
{
|
||||
const billingPricesPerPlan =
|
||||
await this.billingPlanService.getPricesPerPlanByInterval({
|
||||
planKey: checkoutSessionParams.plan,
|
||||
interval: recurringInterval,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// For 7-day trials (no payment method required), create subscription directly
|
||||
// For 30-day trials (payment method required), use checkout session flow
|
||||
@@ -143,10 +139,19 @@ export class BillingResolver {
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionsGuard(PermissionFlagType.WORKSPACE),
|
||||
)
|
||||
async switchToYearlyInterval(@AuthWorkspace() workspace: Workspace) {
|
||||
await this.billingSubscriptionService.switchToYearlyInterval(workspace);
|
||||
async switchSubscriptionInterval(@AuthWorkspace() workspace: Workspace) {
|
||||
await this.billingSubscriptionService.changeInterval(workspace);
|
||||
|
||||
return { success: true };
|
||||
return {
|
||||
billingSubscriptions:
|
||||
await this.billingSubscriptionService.getBillingSubscriptions(
|
||||
workspace.id,
|
||||
),
|
||||
currentBillingSubscription:
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
|
||||
{ workspaceId: workspace.id },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => BillingUpdateOutput)
|
||||
@@ -154,10 +159,19 @@ export class BillingResolver {
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionsGuard(PermissionFlagType.WORKSPACE),
|
||||
)
|
||||
async switchToEnterprisePlan(@AuthWorkspace() workspace: Workspace) {
|
||||
await this.billingSubscriptionService.switchToEnterprisePlan(workspace);
|
||||
async switchBillingPlan(@AuthWorkspace() workspace: Workspace) {
|
||||
await this.billingSubscriptionService.changePlan(workspace);
|
||||
|
||||
return { success: true };
|
||||
return {
|
||||
billingSubscriptions:
|
||||
await this.billingSubscriptionService.getBillingSubscriptions(
|
||||
workspace.id,
|
||||
),
|
||||
currentBillingSubscription:
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
|
||||
{ workspaceId: workspace.id },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => BillingUpdateOutput)
|
||||
@@ -165,22 +179,71 @@ export class BillingResolver {
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionsGuard(PermissionFlagType.WORKSPACE),
|
||||
)
|
||||
async updateSubscriptionItemPrice(
|
||||
async cancelSwitchBillingPlan(@AuthWorkspace() workspace: Workspace) {
|
||||
await this.billingSubscriptionService.cancelSwitchPlan(workspace);
|
||||
|
||||
return {
|
||||
billingSubscriptions:
|
||||
await this.billingSubscriptionService.getBillingSubscriptions(
|
||||
workspace.id,
|
||||
),
|
||||
currentBillingSubscription:
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
|
||||
{ workspaceId: workspace.id },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => BillingUpdateOutput)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionsGuard(PermissionFlagType.WORKSPACE),
|
||||
)
|
||||
async cancelSwitchBillingInterval(@AuthWorkspace() workspace: Workspace) {
|
||||
await this.billingSubscriptionService.cancelSwitchInterval(workspace);
|
||||
|
||||
return {
|
||||
billingSubscriptions:
|
||||
await this.billingSubscriptionService.getBillingSubscriptions(
|
||||
workspace.id,
|
||||
),
|
||||
currentBillingSubscription:
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
|
||||
{ workspaceId: workspace.id },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => BillingUpdateOutput)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionsGuard(PermissionFlagType.WORKSPACE),
|
||||
)
|
||||
async setMeteredSubscriptionPrice(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Args() { priceId }: BillingUpdateSubscriptionItemPriceInput,
|
||||
) {
|
||||
await this.billingService.updateMeteredSubscriptionPrice(
|
||||
workspace.id,
|
||||
await this.billingSubscriptionService.changeMeteredPrice(
|
||||
workspace,
|
||||
priceId,
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
return {
|
||||
billingSubscriptions:
|
||||
await this.billingSubscriptionService.getBillingSubscriptions(
|
||||
workspace.id,
|
||||
),
|
||||
currentBillingSubscription:
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
|
||||
{ workspaceId: workspace.id },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Query(() => [BillingPlanOutput])
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
async plans(): Promise<BillingPlanOutput[]> {
|
||||
const plans = await this.billingPlanService.getPlans();
|
||||
async listPlans(): Promise<BillingPlanOutput[]> {
|
||||
const plans = await this.billingPlanService.listPlans();
|
||||
|
||||
return plans.map(formatBillingDatabaseProductToGraphqlDTO);
|
||||
}
|
||||
@@ -207,32 +270,24 @@ export class BillingResolver {
|
||||
return await this.billingUsageService.getMeteredProductsUsage(workspace);
|
||||
}
|
||||
|
||||
@Query(() => [BillingPriceOutput])
|
||||
@Mutation(() => BillingUpdateOutput)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionsGuard(PermissionFlagType.WORKSPACE),
|
||||
)
|
||||
async listAvailableMeteredBillingPrices(
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<BillingPriceOutput[]> {
|
||||
return (
|
||||
await this.billingService.listMeteredBillingPricesByWorkspaceIdAndProductKey(
|
||||
workspace.id,
|
||||
)
|
||||
).reduce(
|
||||
(acc, billingPrice) =>
|
||||
isDefined(billingPrice.tiers?.[0].flat_amount) &&
|
||||
isDefined(billingPrice.nickname) &&
|
||||
isDefined(billingPrice.interval)
|
||||
? acc.concat({
|
||||
amount: billingPrice.tiers[0].flat_amount,
|
||||
nickname: billingPrice.nickname,
|
||||
stripePriceId: billingPrice.stripePriceId,
|
||||
recurringInterval: billingPrice.interval,
|
||||
})
|
||||
: acc,
|
||||
[] as BillingPriceOutput[],
|
||||
);
|
||||
async cancelSwitchMeteredPrice(@AuthWorkspace() workspace: Workspace) {
|
||||
await this.billingSubscriptionService.cancelSwitchMeteredPrice(workspace);
|
||||
|
||||
return {
|
||||
billingSubscriptions:
|
||||
await this.billingSubscriptionService.getBillingSubscriptions(
|
||||
workspace.id,
|
||||
),
|
||||
currentBillingSubscription:
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
|
||||
{ workspaceId: workspace.id },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private async validateCanCheckoutSessionPermissionOrThrow({
|
||||
|
||||
@@ -6,6 +6,15 @@ import {
|
||||
BillingException,
|
||||
BillingExceptionCode,
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { type 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';
|
||||
import { type BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type';
|
||||
import { type BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import {
|
||||
type LicensedBillingSubscriptionItem,
|
||||
type MeteredBillingSubscriptionItem,
|
||||
} from 'src/engine/core-modules/billing/types/billing-subscription-item.type';
|
||||
import { type BillingSubscriptionWithSubscriptionItems } from 'src/engine/core-modules/billing/types/billing-subscription-with-subscription-items';
|
||||
|
||||
const assertIsMeteredTiersSchemaOrThrow = (
|
||||
tiers: BillingPrice['tiers'] | undefined | null,
|
||||
@@ -40,10 +49,117 @@ const isMeteredTiersSchema = (
|
||||
return true;
|
||||
};
|
||||
|
||||
const assertIsLicensedSubscriptionItem = (
|
||||
subscriptionItem: BillingSubscriptionItem,
|
||||
): asserts subscriptionItem is LicensedBillingSubscriptionItem => {
|
||||
if (
|
||||
subscriptionItem.quantity !== null &&
|
||||
subscriptionItem.billingProduct.metadata.priceUsageBased ===
|
||||
BillingUsageType.LICENSED
|
||||
)
|
||||
return;
|
||||
|
||||
throw new BillingException(
|
||||
'Subscription Item is not a licence subscription item',
|
||||
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_INVALID,
|
||||
);
|
||||
};
|
||||
|
||||
const assertIsMeteredSubscriptionItem = (
|
||||
subscriptionItem: BillingSubscriptionItem,
|
||||
): asserts subscriptionItem is MeteredBillingSubscriptionItem => {
|
||||
if (
|
||||
subscriptionItem.quantity === null &&
|
||||
subscriptionItem.billingProduct.metadata.priceUsageBased ===
|
||||
BillingUsageType.METERED
|
||||
)
|
||||
return;
|
||||
|
||||
throw new BillingException(
|
||||
'Subscription Item is not a meter subscription item',
|
||||
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_INVALID,
|
||||
);
|
||||
};
|
||||
|
||||
const assertIsMeteredPrice = (
|
||||
price: BillingPrice,
|
||||
): asserts price is BillingMeterPrice => {
|
||||
if (
|
||||
price.billingProduct?.metadata.priceUsageBased !== BillingUsageType.METERED
|
||||
) {
|
||||
throw new BillingException(
|
||||
'Price is not a metered price',
|
||||
BillingExceptionCode.BILLING_PRICE_INVALID,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isMeteredTiersSchema(price.tiers)) {
|
||||
throw new BillingException(
|
||||
'Tiers declare in price do not match metered price schema. Price must have exactly two tiers and only one must have a defined limitation (up_to). Example: [{up_to: 100}, {up_to: null}]',
|
||||
BillingExceptionCode.BILLING_PRICE_INVALID,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
const isMeteredPrice = (price: BillingPrice): price is BillingMeterPrice => {
|
||||
if (
|
||||
price.billingProduct?.metadata.priceUsageBased !==
|
||||
BillingUsageType.METERED ||
|
||||
!isMeteredTiersSchema(price.tiers)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const assertIsSubscription = (
|
||||
subscription: BillingSubscription | undefined,
|
||||
): asserts subscription is BillingSubscription &
|
||||
BillingSubscriptionWithSubscriptionItems => {
|
||||
if (!isDefined(subscription)) {
|
||||
throw new BillingException(
|
||||
'Subscription is not defined',
|
||||
BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
if (!isDefined(subscription.billingSubscriptionItems)) {
|
||||
throw new BillingException(
|
||||
'Subscription items is not defined. Check the relation in the query',
|
||||
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (subscription.billingSubscriptionItems.length !== 2) {
|
||||
throw new BillingException(
|
||||
'Subscription must have exactly two subscription items. Check that stripe and database are in sync',
|
||||
BillingExceptionCode.BILLING_SUBSCRIPTION_INVALID,
|
||||
{
|
||||
userFriendlyMessage:
|
||||
'Your billing subscription is corrupted. Please contact support.',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
export const billingValidator: {
|
||||
assertIsMeteredTiersSchemaOrThrow: typeof assertIsMeteredTiersSchemaOrThrow;
|
||||
isMeteredTiersSchema: typeof isMeteredTiersSchema;
|
||||
assertIsLicensedSubscriptionItem: typeof assertIsLicensedSubscriptionItem;
|
||||
assertIsMeteredSubscriptionItem: typeof assertIsMeteredSubscriptionItem;
|
||||
assertIsMeteredPrice: typeof assertIsMeteredPrice;
|
||||
assertIsSubscription: typeof assertIsSubscription;
|
||||
isMeteredPrice: typeof isMeteredPrice;
|
||||
} = {
|
||||
assertIsMeteredTiersSchemaOrThrow,
|
||||
isMeteredTiersSchema,
|
||||
assertIsLicensedSubscriptionItem,
|
||||
assertIsMeteredSubscriptionItem,
|
||||
assertIsMeteredPrice,
|
||||
assertIsSubscription,
|
||||
isMeteredPrice,
|
||||
};
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ export class BillingSyncCustomerDataCommand extends ActiveOrSuspendedWorkspacesM
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (stripeCustomerId) {
|
||||
if (typeof stripeCustomerId === 'string') {
|
||||
await this.billingCustomerRepository.upsert(
|
||||
{
|
||||
stripeCustomerId,
|
||||
|
||||
+1
-5
@@ -3,16 +3,12 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { BillingPriceTierDTO } from 'src/engine/core-modules/billing/dtos/billing-price-tier.dto';
|
||||
import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum';
|
||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||
|
||||
@ObjectType()
|
||||
export class BillingPriceMeteredDTO {
|
||||
@Field(() => BillingPriceTiersMode, { nullable: true })
|
||||
tiersMode: BillingPriceTiersMode.GRADUATED | null;
|
||||
|
||||
@Field(() => [BillingPriceTierDTO], { nullable: true })
|
||||
@Field(() => [BillingPriceTierDTO])
|
||||
tiers: BillingPriceTierDTO[];
|
||||
|
||||
@Field(() => SubscriptionInterval)
|
||||
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { createUnionType } from '@nestjs/graphql';
|
||||
|
||||
import { BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dtos/billing-price-licensed.dto';
|
||||
import { BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto';
|
||||
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||
export const BillingPriceUnionDTO = createUnionType({
|
||||
name: 'BillingPriceUnionDTO',
|
||||
types: () => [BillingPriceLicensedDTO, BillingPriceMeteredDTO],
|
||||
resolveType(value) {
|
||||
if (value.priceUsageType === BillingUsageType.LICENSED) {
|
||||
return BillingPriceLicensedDTO;
|
||||
}
|
||||
|
||||
return BillingPriceMeteredDTO;
|
||||
},
|
||||
});
|
||||
@@ -1,12 +1,18 @@
|
||||
/* @license Enterprise */
|
||||
import { Field, InterfaceType, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { type BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dtos/billing-price-licensed.dto';
|
||||
import { type BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto';
|
||||
import { BillingPriceUnionDTO } from 'src/engine/core-modules/billing/dtos/billing-price-union.dto';
|
||||
import { BillingProductMetadata } from 'src/engine/core-modules/billing/types/billing-product-metadata.type';
|
||||
import { BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dtos/billing-price-licensed.dto';
|
||||
import { BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto';
|
||||
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
|
||||
|
||||
@InterfaceType({
|
||||
resolveType(product: BillingProductDTO) {
|
||||
return product.metadata.productKey ===
|
||||
BillingProductKey.WORKFLOW_NODE_EXECUTION
|
||||
? BillingMeteredProduct
|
||||
: BillingLicensedProduct;
|
||||
},
|
||||
})
|
||||
@ObjectType('BillingProduct')
|
||||
export class BillingProductDTO {
|
||||
@Field(() => String)
|
||||
@@ -18,9 +24,18 @@ export class BillingProductDTO {
|
||||
@Field(() => [String], { nullable: true })
|
||||
images: string[];
|
||||
|
||||
@Field(() => [BillingPriceUnionDTO], { nullable: true })
|
||||
prices: Array<BillingPriceLicensedDTO> | Array<BillingPriceMeteredDTO>;
|
||||
|
||||
@Field(() => BillingProductMetadata)
|
||||
metadata: BillingProductMetadata;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: BillingProductDTO })
|
||||
export class BillingLicensedProduct {
|
||||
@Field(() => [BillingPriceLicensedDTO], { nullable: true })
|
||||
prices: BillingPriceLicensedDTO[] | null;
|
||||
}
|
||||
|
||||
@ObjectType({ implements: BillingProductDTO })
|
||||
export class BillingMeteredProduct {
|
||||
@Field(() => [BillingPriceMeteredDTO], { nullable: true })
|
||||
prices: BillingPriceMeteredDTO[] | null;
|
||||
}
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class BillingSubscriptionSchedulePhaseItem {
|
||||
@Field(() => String)
|
||||
price: string;
|
||||
|
||||
@Field(() => Number, { nullable: true })
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class BillingSubscriptionSchedulePhase {
|
||||
@Field(() => Number)
|
||||
start_date: number;
|
||||
|
||||
@Field(() => Number)
|
||||
end_date: number;
|
||||
|
||||
@Field(() => [BillingSubscriptionSchedulePhaseItem])
|
||||
items: Array<BillingSubscriptionSchedulePhaseItem>;
|
||||
}
|
||||
+8
-8
@@ -2,7 +2,10 @@
|
||||
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { BillingProductDTO } from 'src/engine/core-modules/billing/dtos/billing-product.dto';
|
||||
import {
|
||||
BillingLicensedProduct,
|
||||
BillingMeteredProduct,
|
||||
} from 'src/engine/core-modules/billing/dtos/billing-product.dto';
|
||||
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
|
||||
@ObjectType()
|
||||
@@ -10,12 +13,9 @@ export class BillingPlanOutput {
|
||||
@Field(() => BillingPlanKey)
|
||||
planKey: BillingPlanKey;
|
||||
|
||||
@Field(() => BillingProductDTO)
|
||||
baseProduct: BillingProductDTO;
|
||||
@Field(() => [BillingLicensedProduct])
|
||||
licensedProducts: BillingLicensedProduct[];
|
||||
|
||||
@Field(() => [BillingProductDTO])
|
||||
otherLicensedProducts: BillingProductDTO[];
|
||||
|
||||
@Field(() => [BillingProductDTO])
|
||||
meteredProducts: BillingProductDTO[];
|
||||
@Field(() => [BillingMeteredProduct])
|
||||
meteredProducts: BillingMeteredProduct[];
|
||||
}
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@ import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/bill
|
||||
|
||||
@ObjectType()
|
||||
export class BillingPriceOutput {
|
||||
@Field(() => String)
|
||||
nickname: string;
|
||||
@Field(() => Number)
|
||||
upTo: number;
|
||||
|
||||
@Field(() => Number)
|
||||
amount: number;
|
||||
|
||||
+4
-4
@@ -7,7 +7,7 @@ import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { BillingProductDTO } from 'src/engine/core-modules/billing/dtos/billing-product.dto';
|
||||
|
||||
@ObjectType('BillingSubscriptionItem')
|
||||
@ObjectType()
|
||||
export class BillingSubscriptionItemDTO {
|
||||
@IDField(() => UUIDScalarType)
|
||||
id: string;
|
||||
@@ -18,9 +18,9 @@ export class BillingSubscriptionItemDTO {
|
||||
@Field(() => Number, { nullable: true })
|
||||
quantity: number | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
stripePriceId: string | null;
|
||||
@Field(() => String)
|
||||
stripePriceId: string;
|
||||
|
||||
@Field(() => BillingProductDTO, { nullable: true })
|
||||
@Field(() => BillingProductDTO)
|
||||
billingProduct: BillingProductDTO;
|
||||
}
|
||||
|
||||
+10
-3
@@ -2,10 +2,17 @@
|
||||
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
|
||||
@ObjectType()
|
||||
export class BillingUpdateOutput {
|
||||
@Field(() => Boolean, {
|
||||
description: 'Boolean that confirms query was successful',
|
||||
@Field(() => BillingSubscription, {
|
||||
description: 'Current billing subscription',
|
||||
})
|
||||
success: boolean;
|
||||
currentBillingSubscription: BillingSubscription;
|
||||
|
||||
@Field(() => [BillingSubscription], {
|
||||
description: 'All billing subscriptions',
|
||||
})
|
||||
billingSubscriptions: BillingSubscription[];
|
||||
}
|
||||
|
||||
+5
-13
@@ -19,7 +19,6 @@ import { BillingMeter } from 'src/engine/core-modules/billing/entities/billing-m
|
||||
import { BillingProduct } from 'src/engine/core-modules/billing/entities/billing-product.entity';
|
||||
import { BillingPriceBillingScheme } from 'src/engine/core-modules/billing/enums/billing-price-billing-scheme.enum';
|
||||
import { BillingPriceTaxBehavior } from 'src/engine/core-modules/billing/enums/billing-price-tax-behavior.enum';
|
||||
import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum';
|
||||
import { BillingPriceType } from 'src/engine/core-modules/billing/enums/billing-price-type.enum';
|
||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||
@@ -86,13 +85,6 @@ export class BillingPrice {
|
||||
@Column({ nullable: true, type: 'jsonb' })
|
||||
transformQuantity: Stripe.Price.TransformQuantity | null;
|
||||
|
||||
@Column({
|
||||
nullable: true,
|
||||
type: 'enum',
|
||||
enum: Object.values(BillingPriceTiersMode),
|
||||
})
|
||||
tiersMode: BillingPriceTiersMode | null;
|
||||
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
unitAmountDecimal: string | null;
|
||||
|
||||
@@ -110,26 +102,26 @@ export class BillingPrice {
|
||||
})
|
||||
usageType: BillingUsageType;
|
||||
|
||||
@Field(() => SubscriptionInterval, { nullable: true })
|
||||
@Field(() => SubscriptionInterval)
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: Object.values(SubscriptionInterval),
|
||||
nullable: true,
|
||||
})
|
||||
interval: SubscriptionInterval | null;
|
||||
interval: SubscriptionInterval;
|
||||
|
||||
@ManyToOne(
|
||||
() => BillingProduct,
|
||||
(billingProduct) => billingProduct.billingPrices,
|
||||
{
|
||||
onDelete: 'CASCADE',
|
||||
nullable: true,
|
||||
},
|
||||
)
|
||||
@JoinColumn({
|
||||
referencedColumnName: 'stripeProductId',
|
||||
name: 'stripeProductId',
|
||||
})
|
||||
billingProduct: Relation<BillingProduct>;
|
||||
billingProduct: Relation<BillingProduct> | null;
|
||||
|
||||
@ManyToOne(() => BillingMeter, (billingMeter) => billingMeter.billingPrices, {
|
||||
nullable: true,
|
||||
@@ -138,5 +130,5 @@ export class BillingPrice {
|
||||
referencedColumnName: 'stripeMeterId',
|
||||
name: 'stripeMeterId',
|
||||
})
|
||||
billingMeter: Relation<BillingMeter>;
|
||||
billingMeter: Relation<BillingMeter> | null;
|
||||
}
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ export class BillingProduct {
|
||||
id: string;
|
||||
|
||||
@Column({ nullable: true, type: 'timestamptz' })
|
||||
deletedAt?: Date;
|
||||
deletedAt?: Date | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
+6
-1
@@ -25,6 +25,7 @@ import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entitie
|
||||
import { BillingSubscriptionCollectionMethod } from 'src/engine/core-modules/billing/enums/billing-subscription-collection-method.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 { BillingSubscriptionSchedulePhase } from 'src/engine/core-modules/billing/dtos/billing-subscription-schedule-phase.dto';
|
||||
|
||||
registerEnumType(SubscriptionStatus, { name: 'SubscriptionStatus' });
|
||||
registerEnumType(SubscriptionInterval, { name: 'SubscriptionInterval' });
|
||||
@@ -72,7 +73,7 @@ export class BillingSubscription {
|
||||
enum: Object.values(SubscriptionInterval),
|
||||
nullable: true,
|
||||
})
|
||||
interval: Stripe.Price.Recurring.Interval;
|
||||
interval: SubscriptionInterval;
|
||||
|
||||
@Field(() => [BillingSubscriptionItemDTO], { nullable: true })
|
||||
@OneToMany(
|
||||
@@ -121,6 +122,10 @@ export class BillingSubscription {
|
||||
@Column({ nullable: false, type: 'jsonb', default: {} })
|
||||
metadata: Stripe.Metadata;
|
||||
|
||||
@Field(() => [BillingSubscriptionSchedulePhase])
|
||||
@Column({ nullable: false, type: 'jsonb', default: [] })
|
||||
phases: Array<BillingSubscriptionSchedulePhase>;
|
||||
|
||||
@Column({ nullable: true, type: 'timestamptz' })
|
||||
cancelAt: Date | null;
|
||||
|
||||
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
export enum BillingPriceTiersMode {
|
||||
GRADUATED = 'GRADUATED',
|
||||
VOLUME = 'VOLUME',
|
||||
}
|
||||
registerEnumType(BillingPriceTiersMode, {
|
||||
name: 'BillingPriceTiersMode',
|
||||
description: 'The different billing price tiers modes',
|
||||
});
|
||||
-2
@@ -1,8 +1,6 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
export enum SubscriptionInterval {
|
||||
Day = 'day',
|
||||
Month = 'month',
|
||||
Week = 'week',
|
||||
Year = 'year',
|
||||
}
|
||||
|
||||
+1
@@ -13,4 +13,5 @@ export enum BillingWebhookEvent {
|
||||
PRICE_UPDATED = 'price.updated',
|
||||
ALERT_TRIGGERED = 'billing.alert.triggered',
|
||||
INVOICE_FINALIZED = 'invoice.finalized',
|
||||
SUBSCRIPTION_SCHEDULE_UPDATED = 'subscription_schedule.updated',
|
||||
}
|
||||
|
||||
+36
-47
@@ -4,6 +4,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { JsonContains, Repository } from 'typeorm';
|
||||
import { findOrThrow } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
BillingException,
|
||||
@@ -34,7 +35,7 @@ export class BillingPlanService {
|
||||
priceUsageBased: BillingUsageType;
|
||||
productKey: BillingProductKey;
|
||||
}): Promise<BillingProduct[]> {
|
||||
const products = await this.billingProductRepository.find({
|
||||
return await this.billingProductRepository.find({
|
||||
where: {
|
||||
metadata: JsonContains({
|
||||
priceUsageBased,
|
||||
@@ -45,8 +46,6 @@ export class BillingPlanService {
|
||||
},
|
||||
relations: ['billingPrices'],
|
||||
});
|
||||
|
||||
return products;
|
||||
}
|
||||
|
||||
async getPlanBaseProduct(planKey: BillingPlanKey): Promise<BillingProduct> {
|
||||
@@ -59,66 +58,68 @@ export class BillingPlanService {
|
||||
return baseProduct;
|
||||
}
|
||||
|
||||
async getPlans(): Promise<BillingGetPlanResult[]> {
|
||||
async listPlans(): Promise<BillingGetPlanResult[]> {
|
||||
const planKeys = Object.values(BillingPlanKey);
|
||||
|
||||
const products = await this.billingProductRepository.find({
|
||||
where: {
|
||||
active: true,
|
||||
billingPrices: {
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
relations: ['billingPrices.billingProduct'],
|
||||
});
|
||||
|
||||
return planKeys.map((planKey) => {
|
||||
const planProducts = products
|
||||
.filter((product) => product.metadata.planKey === planKey)
|
||||
.map((product) => {
|
||||
return {
|
||||
...product,
|
||||
billingPrices: product.billingPrices.filter(
|
||||
(price) => price.active,
|
||||
),
|
||||
};
|
||||
});
|
||||
const baseProduct = planProducts.find(
|
||||
(product) =>
|
||||
product.metadata.productKey === BillingProductKey.BASE_PRODUCT,
|
||||
const planProducts = products.filter(
|
||||
(product) => product.metadata.planKey === planKey,
|
||||
);
|
||||
|
||||
if (!baseProduct) {
|
||||
throw new BillingException(
|
||||
'Base product not found, did you run the billing:sync-plans-data command?',
|
||||
BillingExceptionCode.BILLING_PRODUCT_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const meteredProducts = planProducts.filter(
|
||||
(product) =>
|
||||
product.metadata.priceUsageBased === BillingUsageType.METERED,
|
||||
);
|
||||
const otherLicensedProducts = planProducts.filter(
|
||||
const licensedProducts = planProducts.filter(
|
||||
(product) =>
|
||||
product.metadata.priceUsageBased === BillingUsageType.LICENSED &&
|
||||
product.metadata.productKey !== BillingProductKey.BASE_PRODUCT,
|
||||
product.metadata.priceUsageBased === BillingUsageType.LICENSED,
|
||||
);
|
||||
|
||||
return {
|
||||
planKey,
|
||||
baseProduct,
|
||||
meteredProducts,
|
||||
otherLicensedProducts,
|
||||
licensedProducts,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getPricesPerPlan({
|
||||
async getPlanByPriceId(stripePriceId: string) {
|
||||
const plans = await this.listPlans();
|
||||
|
||||
return findOrThrow(plans, (plan) => {
|
||||
return (
|
||||
plan.meteredProducts.some((product) =>
|
||||
product.billingPrices.some(
|
||||
(price) => price.stripePriceId === stripePriceId,
|
||||
),
|
||||
) ||
|
||||
plan.licensedProducts.some((product) =>
|
||||
product.billingPrices.some(
|
||||
(price) => price.stripePriceId === stripePriceId,
|
||||
),
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async getPricesPerPlanByInterval({
|
||||
planKey,
|
||||
interval,
|
||||
}: {
|
||||
planKey: BillingPlanKey;
|
||||
interval: SubscriptionInterval;
|
||||
}): Promise<BillingGetPricesPerPlanResult> {
|
||||
const plans = await this.getPlans();
|
||||
const plans = await this.listPlans();
|
||||
const plan = plans.find((plan) => plan.planKey === planKey);
|
||||
|
||||
if (!plan) {
|
||||
@@ -127,33 +128,21 @@ export class BillingPlanService {
|
||||
BillingExceptionCode.BILLING_PLAN_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
const { baseProduct, meteredProducts, otherLicensedProducts } = plan;
|
||||
const baseProductPrice = baseProduct.billingPrices.find(
|
||||
(price) => price.interval === interval && price.active,
|
||||
);
|
||||
const { meteredProducts, licensedProducts } = plan;
|
||||
|
||||
if (!baseProductPrice) {
|
||||
throw new BillingException(
|
||||
'Base product active price not found for given interval',
|
||||
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
const filterPricesByInterval = (product: BillingProduct) =>
|
||||
product.billingPrices.filter(
|
||||
(price) => price.interval === interval && price.active,
|
||||
);
|
||||
product.billingPrices.filter((price) => price.interval === interval);
|
||||
|
||||
const meteredProductsPrices = meteredProducts.flatMap(
|
||||
filterPricesByInterval,
|
||||
);
|
||||
const otherLicensedProductsPrices = otherLicensedProducts.flatMap(
|
||||
const licensedProductsPrices = licensedProducts.flatMap(
|
||||
filterPricesByInterval,
|
||||
);
|
||||
|
||||
return {
|
||||
baseProductPrice,
|
||||
meteredProductsPrices,
|
||||
otherLicensedProductsPrices,
|
||||
licensedProductsPrices,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
-443
@@ -1,443 +0,0 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import type { Repository } from 'typeorm';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { BillingPortalWorkspaceService } from 'src/engine/core-modules/billing/services/billing-portal.workspace-service';
|
||||
import { StripeCheckoutService } from 'src/engine/core-modules/billing/stripe/services/stripe-checkout.service';
|
||||
import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.entity';
|
||||
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||
import { BillingExceptionCode } from 'src/engine/core-modules/billing/billing.exception';
|
||||
|
||||
const buildWorkspace = (id: string): Workspace =>
|
||||
({
|
||||
id,
|
||||
name: 'WS',
|
||||
}) as unknown as Workspace;
|
||||
|
||||
const buildPricesPerPlan = () => ({
|
||||
baseProductPrice: { stripePriceId: 'price_base' } as BillingPrice,
|
||||
meteredProductsPrices: [
|
||||
{
|
||||
stripePriceId: 'price_metered_default',
|
||||
tiers: [
|
||||
{ flat_amount: 1000, up_to: 100 },
|
||||
{ flat_amount: 0, up_to: null },
|
||||
],
|
||||
} as unknown as BillingPrice,
|
||||
],
|
||||
otherLicensedProductsPrices: [],
|
||||
});
|
||||
|
||||
const buildStripeSubscription = (id = 'sub_123'): Stripe.Subscription =>
|
||||
({
|
||||
id,
|
||||
status: 'active',
|
||||
currency: 'usd',
|
||||
current_period_start: 1700000000,
|
||||
current_period_end: 1702592000,
|
||||
cancel_at_period_end: false,
|
||||
collection_method: 'charge_automatically',
|
||||
automatic_tax: null,
|
||||
cancellation_details: null,
|
||||
trial_start: null,
|
||||
trial_end: null,
|
||||
cancel_at: null,
|
||||
canceled_at: null,
|
||||
customer: 'cus_123',
|
||||
items: {
|
||||
data: [
|
||||
{
|
||||
id: 'si_1',
|
||||
price: { id: 'price_base', product: 'prod_base' },
|
||||
plan: { interval: 'month' },
|
||||
},
|
||||
{
|
||||
id: 'si_2',
|
||||
price: { id: 'price_metered_default', product: 'prod_metered' },
|
||||
plan: { interval: 'month' },
|
||||
},
|
||||
],
|
||||
},
|
||||
metadata: {},
|
||||
}) as unknown as Stripe.Subscription;
|
||||
|
||||
describe('BillingPortalWorkspaceService', () => {
|
||||
let service: BillingPortalWorkspaceService;
|
||||
let stripeCheckoutService: StripeCheckoutService;
|
||||
let billingSubscriptionRepository: Repository<BillingSubscription>;
|
||||
let billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>;
|
||||
let billingCustomerRepository: Repository<BillingCustomer>;
|
||||
let userWorkspaceRepository: Repository<UserWorkspace>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BillingPortalWorkspaceService,
|
||||
{
|
||||
provide: StripeCheckoutService,
|
||||
useValue: { createDirectSubscription: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: StripeBillingPortalService,
|
||||
useValue: { createBillingPortalSession: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: DomainManagerService,
|
||||
useValue: {
|
||||
buildWorkspaceURL: jest.fn(
|
||||
() => new URL('https://app.local/workspace'),
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: BillingSubscriptionService,
|
||||
useValue: {
|
||||
setBillingThresholdsAndTrialPeriodWorkflowCredits: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(BillingSubscription),
|
||||
useValue: {
|
||||
upsert: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
findOneBy: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(BillingSubscriptionItem),
|
||||
useValue: { upsert: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(BillingCustomer),
|
||||
useValue: { upsert: jest.fn(), findOne: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserWorkspace),
|
||||
useValue: { countBy: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(BillingPortalWorkspaceService);
|
||||
stripeCheckoutService = module.get(StripeCheckoutService);
|
||||
billingSubscriptionRepository = module.get(
|
||||
getRepositoryToken(BillingSubscription),
|
||||
);
|
||||
billingSubscriptionItemRepository = module.get(
|
||||
getRepositoryToken(BillingSubscriptionItem),
|
||||
);
|
||||
billingCustomerRepository = module.get(getRepositoryToken(BillingCustomer));
|
||||
userWorkspaceRepository = module.get(getRepositoryToken(UserWorkspace));
|
||||
});
|
||||
|
||||
it('creates a direct subscription and syncs to database, returning success URL', async () => {
|
||||
const workspace = buildWorkspace('ws-1');
|
||||
|
||||
(userWorkspaceRepository.countBy as jest.Mock).mockResolvedValue(3);
|
||||
(billingCustomerRepository.findOne as jest.Mock).mockResolvedValue({
|
||||
workspaceId: workspace.id,
|
||||
billingSubscriptions: [],
|
||||
stripeCustomerId: 'cus_123',
|
||||
} as unknown as BillingCustomer);
|
||||
|
||||
const subscription = buildStripeSubscription('sub_test');
|
||||
|
||||
(
|
||||
stripeCheckoutService.createDirectSubscription as jest.Mock
|
||||
).mockResolvedValue(subscription);
|
||||
|
||||
// After upserts, the repo.find should return the created subscription mapping
|
||||
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
|
||||
{
|
||||
id: 'db_sub_1',
|
||||
workspaceId: workspace.id,
|
||||
stripeSubscriptionId: 'sub_other',
|
||||
},
|
||||
{
|
||||
id: 'db_sub_created',
|
||||
workspaceId: workspace.id,
|
||||
stripeSubscriptionId: 'sub_test',
|
||||
},
|
||||
]);
|
||||
|
||||
const url = await service.createDirectSubscription({
|
||||
user: { id: 'user_1' } as any,
|
||||
workspace,
|
||||
billingPricesPerPlan: buildPricesPerPlan(),
|
||||
successUrlPath: '/billing/success',
|
||||
plan: 'PRO' as any,
|
||||
requirePaymentMethod: false,
|
||||
});
|
||||
|
||||
expect(url).toBe('https://app.local/billing/success');
|
||||
|
||||
// Ensure stripe call built line items properly
|
||||
const callArgs = (
|
||||
stripeCheckoutService.createDirectSubscription as jest.Mock
|
||||
).mock.calls[0][0];
|
||||
|
||||
expect(callArgs.workspace.id).toBe(workspace.id);
|
||||
expect(callArgs.stripeSubscriptionLineItems).toEqual([
|
||||
{ price: 'price_base', quantity: 3 },
|
||||
{ price: 'price_metered_default' },
|
||||
]);
|
||||
expect(callArgs.withTrialPeriod).toBe(true); // no previous subscriptions
|
||||
|
||||
// Sync to DB operations
|
||||
expect(billingCustomerRepository.upsert).toHaveBeenCalled();
|
||||
expect(billingSubscriptionRepository.upsert).toHaveBeenCalled();
|
||||
expect(billingSubscriptionItemRepository.upsert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when missing billing prices per plan (line items cannot be built)', async () => {
|
||||
const workspace = buildWorkspace('ws-1');
|
||||
|
||||
(userWorkspaceRepository.countBy as jest.Mock).mockResolvedValue(1);
|
||||
(billingCustomerRepository.findOne as jest.Mock).mockResolvedValue({
|
||||
workspaceId: workspace.id,
|
||||
billingSubscriptions: [],
|
||||
stripeCustomerId: 'cus_123',
|
||||
} as unknown as BillingCustomer);
|
||||
|
||||
await expect(
|
||||
service.createDirectSubscription({
|
||||
user: { id: 'user_1' } as any,
|
||||
workspace,
|
||||
billingPricesPerPlan: undefined as any,
|
||||
successUrlPath: '/billing/success',
|
||||
plan: 'PRO' as any,
|
||||
requirePaymentMethod: false,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not include trial period when customer already has subscriptions', async () => {
|
||||
const workspace = buildWorkspace('ws-1');
|
||||
|
||||
(userWorkspaceRepository.countBy as jest.Mock).mockResolvedValue(5);
|
||||
(billingCustomerRepository.findOne as jest.Mock).mockResolvedValue({
|
||||
workspaceId: workspace.id,
|
||||
billingSubscriptions: [{}],
|
||||
stripeCustomerId: 'cus_999',
|
||||
} as BillingCustomer);
|
||||
|
||||
const subscription = buildStripeSubscription('sub_no_trial');
|
||||
|
||||
(
|
||||
stripeCheckoutService.createDirectSubscription as jest.Mock
|
||||
).mockResolvedValue(subscription);
|
||||
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
|
||||
{
|
||||
id: 'db_sub_created',
|
||||
workspaceId: workspace.id,
|
||||
stripeSubscriptionId: 'sub_no_trial',
|
||||
},
|
||||
]);
|
||||
|
||||
const url = await service.createDirectSubscription({
|
||||
user: { id: 'user_1' } as any,
|
||||
workspace,
|
||||
billingPricesPerPlan: buildPricesPerPlan(),
|
||||
successUrlPath: '/done',
|
||||
plan: 'PRO' as any,
|
||||
requirePaymentMethod: true,
|
||||
});
|
||||
|
||||
expect(url).toBe('https://app.local/done');
|
||||
|
||||
const callArgs = (
|
||||
stripeCheckoutService.createDirectSubscription as jest.Mock
|
||||
).mock.calls[0][0];
|
||||
|
||||
expect(callArgs.withTrialPeriod).toBe(false);
|
||||
});
|
||||
|
||||
it('throws if subscription not found after creation during sync', async () => {
|
||||
const workspace = buildWorkspace('ws-1');
|
||||
|
||||
(userWorkspaceRepository.countBy as jest.Mock).mockResolvedValue(2);
|
||||
(billingCustomerRepository.findOne as jest.Mock).mockResolvedValue({
|
||||
workspaceId: workspace.id,
|
||||
billingSubscriptions: [],
|
||||
stripeCustomerId: 'cus_123',
|
||||
} as unknown as BillingCustomer);
|
||||
|
||||
const subscription = buildStripeSubscription('sub_missing');
|
||||
|
||||
(
|
||||
stripeCheckoutService.createDirectSubscription as jest.Mock
|
||||
).mockResolvedValue(subscription);
|
||||
|
||||
// Return list that doesn't include the just-created subscription id
|
||||
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
|
||||
{
|
||||
id: 'db_sub_other',
|
||||
workspaceId: workspace.id,
|
||||
stripeSubscriptionId: 'sub_other',
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.createDirectSubscription({
|
||||
user: { id: 'user_1' } as any,
|
||||
workspace,
|
||||
billingPricesPerPlan: buildPricesPerPlan(),
|
||||
successUrlPath: '/billing/success',
|
||||
plan: 'PRO' as any,
|
||||
requirePaymentMethod: false,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
it('picks the metered price with the lowest first tier flat_amount among many', async () => {
|
||||
const workspace = buildWorkspace('ws-x');
|
||||
const prices = {
|
||||
baseProductPrice: { stripePriceId: 'price_base' } as BillingPrice,
|
||||
meteredProductsPrices: [
|
||||
{
|
||||
stripePriceId: 'price_metered_A',
|
||||
tiers: [
|
||||
{ flat_amount: 1200, up_to: 100 },
|
||||
{ flat_amount: 0, up_to: null },
|
||||
],
|
||||
} as unknown as BillingPrice,
|
||||
{
|
||||
stripePriceId: 'price_metered_B',
|
||||
tiers: [
|
||||
{ flat_amount: 800, up_to: 100 },
|
||||
{ flat_amount: 0, up_to: null },
|
||||
],
|
||||
} as unknown as BillingPrice,
|
||||
{
|
||||
stripePriceId: 'price_metered_C',
|
||||
tiers: [
|
||||
{ flat_amount: 900, up_to: 100 },
|
||||
{ flat_amount: 0, up_to: null },
|
||||
],
|
||||
} as unknown as BillingPrice,
|
||||
],
|
||||
otherLicensedProductsPrices: [],
|
||||
};
|
||||
|
||||
// set specific mocks for this scenario
|
||||
(
|
||||
stripeCheckoutService.createDirectSubscription as jest.Mock
|
||||
).mockResolvedValue(buildStripeSubscription('sub_x'));
|
||||
(billingCustomerRepository.findOne as jest.Mock).mockResolvedValue({
|
||||
workspaceId: workspace.id,
|
||||
billingSubscriptions: [],
|
||||
stripeCustomerId: 'cus_x',
|
||||
});
|
||||
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
|
||||
{
|
||||
id: 'db_sub_created_x',
|
||||
workspaceId: workspace.id,
|
||||
stripeSubscriptionId: 'sub_x',
|
||||
},
|
||||
]);
|
||||
(userWorkspaceRepository.countBy as jest.Mock).mockResolvedValue(2);
|
||||
|
||||
await service.createDirectSubscription({
|
||||
user: { id: 'u1' } as any,
|
||||
workspace,
|
||||
billingPricesPerPlan: prices as any,
|
||||
successUrlPath: '/ok',
|
||||
plan: 'PRO' as any,
|
||||
requirePaymentMethod: false,
|
||||
});
|
||||
|
||||
const args = (stripeCheckoutService.createDirectSubscription as jest.Mock)
|
||||
.mock.calls[0][0];
|
||||
const lineItems = args.stripeSubscriptionLineItems as any[];
|
||||
|
||||
expect(lineItems[1]).toEqual({ price: 'price_metered_B' });
|
||||
});
|
||||
|
||||
it('ignores non-metered tiers shapes and still picks the valid lowest flat_amount', async () => {
|
||||
const workspace = buildWorkspace('ws-y');
|
||||
const prices = {
|
||||
baseProductPrice: { stripePriceId: 'price_base' } as BillingPrice,
|
||||
meteredProductsPrices: [
|
||||
// invalid tiers shape (missing flat_amount), should be ignored by validator
|
||||
{ stripePriceId: 'price_invalid', tiers: [{ up_to: 100 }] },
|
||||
{
|
||||
stripePriceId: 'price_valid',
|
||||
tiers: [
|
||||
{ flat_amount: 700, up_to: 50 },
|
||||
{ flat_amount: 0, up_to: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
otherLicensedProductsPrices: [],
|
||||
} as any;
|
||||
|
||||
// set specific mocks for this scenario
|
||||
(
|
||||
stripeCheckoutService.createDirectSubscription as jest.Mock
|
||||
).mockResolvedValue(buildStripeSubscription('sub_x'));
|
||||
(billingCustomerRepository.findOne as jest.Mock).mockResolvedValue({
|
||||
workspaceId: workspace.id,
|
||||
billingSubscriptions: [],
|
||||
stripeCustomerId: 'cus_x',
|
||||
});
|
||||
(billingSubscriptionRepository.find as jest.Mock).mockResolvedValue([
|
||||
{
|
||||
id: 'db_sub_created_x',
|
||||
workspaceId: workspace.id,
|
||||
stripeSubscriptionId: 'sub_x',
|
||||
},
|
||||
]);
|
||||
(userWorkspaceRepository.countBy as jest.Mock).mockResolvedValue(2);
|
||||
|
||||
await service.createDirectSubscription({
|
||||
user: { id: 'u2' } as any,
|
||||
workspace,
|
||||
billingPricesPerPlan: prices,
|
||||
successUrlPath: '/ok',
|
||||
plan: 'PRO' as any,
|
||||
requirePaymentMethod: false,
|
||||
});
|
||||
|
||||
const args = (stripeCheckoutService.createDirectSubscription as jest.Mock)
|
||||
.mock.calls[0][0];
|
||||
const lineItems = args.stripeSubscriptionLineItems as any[];
|
||||
|
||||
expect(lineItems[1]).toEqual({ price: 'price_invalid' }); // current implementation keeps first entry even if tiers are invalid
|
||||
});
|
||||
|
||||
it('throws BILLING_PRICE_NOT_FOUND when meteredProductsPrices is empty', async () => {
|
||||
const workspace = buildWorkspace('ws-z');
|
||||
const prices = {
|
||||
baseProductPrice: { stripePriceId: 'price_base' } as BillingPrice,
|
||||
meteredProductsPrices: [],
|
||||
otherLicensedProductsPrices: [],
|
||||
} as any;
|
||||
|
||||
await expect(
|
||||
service.createDirectSubscription({
|
||||
user: { id: 'u3' } as any,
|
||||
workspace,
|
||||
billingPricesPerPlan: prices,
|
||||
successUrlPath: '/ok',
|
||||
plan: 'PRO' as any,
|
||||
requirePaymentMethod: false,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
});
|
||||
+45
-103
@@ -3,20 +3,16 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
import { findOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import { Not, Repository } from 'typeorm';
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { transformStripeSubscriptionEventToDatabaseCustomer } from 'src/engine/core-modules/billing-webhook/utils/transform-stripe-subscription-event-to-database-customer.util';
|
||||
import { transformStripeSubscriptionEventToDatabaseSubscriptionItem } from 'src/engine/core-modules/billing-webhook/utils/transform-stripe-subscription-event-to-database-subscription-item.util';
|
||||
import { transformStripeSubscriptionEventToDatabaseSubscription } from 'src/engine/core-modules/billing-webhook/utils/transform-stripe-subscription-event-to-database-subscription.util';
|
||||
import {
|
||||
BillingException,
|
||||
BillingExceptionCode,
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { BillingCustomer } from 'src/engine/core-modules/billing/entities/billing-customer.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 { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { StripeBillingPortalService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-portal.service';
|
||||
@@ -27,9 +23,10 @@ import { DomainManagerService } from 'src/engine/core-modules/domain-manager/ser
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { assert } from 'src/utils/assert';
|
||||
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
|
||||
import { MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type';
|
||||
import { BillingMeterPrice } from 'src/engine/core-modules/billing/types/billing-meter-price.type';
|
||||
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
|
||||
import { SubscriptionStatus } from 'src/engine/core-modules/billing/enums/billing-subscription-status.enum';
|
||||
|
||||
@Injectable()
|
||||
export class BillingPortalWorkspaceService {
|
||||
@@ -41,8 +38,6 @@ export class BillingPortalWorkspaceService {
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
@InjectRepository(BillingSubscription)
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
||||
@InjectRepository(BillingSubscriptionItem)
|
||||
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
|
||||
@InjectRepository(BillingCustomer)
|
||||
private readonly billingCustomerRepository: Repository<BillingCustomer>,
|
||||
@InjectRepository(UserWorkspace)
|
||||
@@ -106,11 +101,17 @@ export class BillingPortalWorkspaceService {
|
||||
stripeCustomerId: customer?.stripeCustomerId,
|
||||
plan,
|
||||
requirePaymentMethod,
|
||||
withTrialPeriod:
|
||||
!isDefined(customer) || customer.billingSubscriptions.length === 0,
|
||||
});
|
||||
|
||||
await this.syncSubscriptionToDatabase(workspace.id, subscription);
|
||||
const createdBillingSubscription =
|
||||
await this.billingSubscriptionService.syncSubscriptionToDatabase(
|
||||
workspace.id,
|
||||
subscription,
|
||||
);
|
||||
|
||||
await this.billingSubscriptionService.setBillingThresholdsAndTrialPeriodWorkflowCredits(
|
||||
createdBillingSubscription.id,
|
||||
);
|
||||
|
||||
return successUrl;
|
||||
}
|
||||
@@ -121,7 +122,7 @@ export class BillingPortalWorkspaceService {
|
||||
successUrlPath,
|
||||
}: {
|
||||
workspace: Workspace;
|
||||
billingPricesPerPlan?: BillingGetPricesPerPlanResult;
|
||||
billingPricesPerPlan: BillingGetPricesPerPlanResult;
|
||||
successUrlPath?: string;
|
||||
}) {
|
||||
const frontBaseUrl = this.domainManagerService.buildWorkspaceURL({
|
||||
@@ -157,72 +158,15 @@ export class BillingPortalWorkspaceService {
|
||||
};
|
||||
}
|
||||
|
||||
private async syncSubscriptionToDatabase(
|
||||
workspaceId: string,
|
||||
subscription: Stripe.Subscription,
|
||||
) {
|
||||
await this.billingCustomerRepository.upsert(
|
||||
transformStripeSubscriptionEventToDatabaseCustomer(workspaceId, {
|
||||
object: subscription,
|
||||
}),
|
||||
{
|
||||
conflictPaths: ['workspaceId'],
|
||||
skipUpdateIfNoValuesChanged: true,
|
||||
},
|
||||
);
|
||||
|
||||
await this.billingSubscriptionRepository.upsert(
|
||||
transformStripeSubscriptionEventToDatabaseSubscription(workspaceId, {
|
||||
object: subscription,
|
||||
}),
|
||||
{
|
||||
conflictPaths: ['stripeSubscriptionId'],
|
||||
skipUpdateIfNoValuesChanged: true,
|
||||
},
|
||||
);
|
||||
|
||||
const billingSubscriptions = await this.billingSubscriptionRepository.find({
|
||||
where: { workspaceId },
|
||||
});
|
||||
|
||||
const createdBillingSubscription = billingSubscriptions.find(
|
||||
(sub) => sub.stripeSubscriptionId === subscription.id,
|
||||
);
|
||||
|
||||
if (!createdBillingSubscription) {
|
||||
throw new BillingException(
|
||||
'Billing subscription not found after creation',
|
||||
BillingExceptionCode.BILLING_SUBSCRIPTION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
await this.billingSubscriptionItemRepository.upsert(
|
||||
transformStripeSubscriptionEventToDatabaseSubscriptionItem(
|
||||
createdBillingSubscription.id,
|
||||
{
|
||||
object: subscription,
|
||||
},
|
||||
),
|
||||
{
|
||||
conflictPaths: ['stripeSubscriptionItemId'],
|
||||
skipUpdateIfNoValuesChanged: true,
|
||||
},
|
||||
);
|
||||
|
||||
await this.billingSubscriptionService.setBillingThresholdsAndTrialPeriodWorkflowCredits(
|
||||
createdBillingSubscription.id,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Subscription synced to database: ${subscription.id} for workspace: ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
async computeBillingPortalSessionURLOrThrow(
|
||||
workspace: Workspace,
|
||||
returnUrlPath?: string,
|
||||
) {
|
||||
const lastSubscription = await this.billingSubscriptionRepository.findOne({
|
||||
where: { workspaceId: workspace.id },
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
status: Not(SubscriptionStatus.Canceled),
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
@@ -258,30 +202,24 @@ export class BillingPortalWorkspaceService {
|
||||
|
||||
private getDefaultMeteredProductPrice(
|
||||
billingPricesPerPlan: BillingGetPricesPerPlanResult,
|
||||
): BillingPrice & {
|
||||
tiers: MeterBillingPriceTiers;
|
||||
} {
|
||||
): BillingMeterPrice {
|
||||
const defaultMeteredProductPrice =
|
||||
billingPricesPerPlan.meteredProductsPrices.reduce(
|
||||
(result, billingPrice) => {
|
||||
if (!result) {
|
||||
return billingPrice as BillingPrice & {
|
||||
tiers: MeterBillingPriceTiers;
|
||||
};
|
||||
return billingPrice as BillingMeterPrice;
|
||||
}
|
||||
const tiers = billingPrice.tiers;
|
||||
|
||||
if (billingValidator.isMeteredTiersSchema(tiers)) {
|
||||
if (tiers[0].flat_amount < result.tiers[0].flat_amount) {
|
||||
return billingPrice as BillingPrice & {
|
||||
tiers: MeterBillingPriceTiers;
|
||||
};
|
||||
return billingPrice as BillingMeterPrice;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
null as (BillingPrice & { tiers: MeterBillingPriceTiers }) | null,
|
||||
null as BillingMeterPrice | null,
|
||||
);
|
||||
|
||||
if (!isDefined(defaultMeteredProductPrice)) {
|
||||
@@ -299,26 +237,30 @@ export class BillingPortalWorkspaceService {
|
||||
billingPricesPerPlan,
|
||||
}: {
|
||||
quantity: number;
|
||||
billingPricesPerPlan?: BillingGetPricesPerPlanResult;
|
||||
billingPricesPerPlan: BillingGetPricesPerPlanResult;
|
||||
}): Stripe.Checkout.SessionCreateParams.LineItem[] {
|
||||
if (billingPricesPerPlan) {
|
||||
const defaultMeteredProductPrice =
|
||||
this.getDefaultMeteredProductPrice(billingPricesPerPlan);
|
||||
const defaultMeteredProductPrice =
|
||||
this.getDefaultMeteredProductPrice(billingPricesPerPlan);
|
||||
|
||||
return [
|
||||
{
|
||||
price: billingPricesPerPlan.baseProductPrice.stripePriceId,
|
||||
quantity,
|
||||
},
|
||||
{
|
||||
price: defaultMeteredProductPrice.stripePriceId,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
throw new BillingException(
|
||||
'Missing Billing prices per plan',
|
||||
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
|
||||
const defaultLicensedProductPrice = findOrThrow(
|
||||
billingPricesPerPlan.licensedProductsPrices,
|
||||
(licensedProductsPrice) =>
|
||||
licensedProductsPrice.billingProduct?.metadata.productKey ===
|
||||
BillingProductKey.BASE_PRODUCT,
|
||||
new BillingException(
|
||||
`Base product not found`,
|
||||
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
price: defaultLicensedProductPrice.stripePriceId,
|
||||
quantity,
|
||||
},
|
||||
{
|
||||
price: defaultMeteredProductPrice.stripePriceId,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -47,7 +47,7 @@ export class BillingProductService {
|
||||
}
|
||||
|
||||
async getProductsByPlan(planKey: BillingPlanKey): Promise<BillingProduct[]> {
|
||||
const products = await this.billingPlanService.getPlans();
|
||||
const products = await this.billingPlanService.listPlans();
|
||||
const plan = products.find((product) => product.planKey === planKey);
|
||||
|
||||
if (!plan) {
|
||||
@@ -57,6 +57,6 @@ export class BillingProductService {
|
||||
);
|
||||
}
|
||||
|
||||
return [plan.baseProduct, ...plan.meteredProducts];
|
||||
return [...plan.licensedProducts, ...plan.meteredProducts];
|
||||
}
|
||||
}
|
||||
|
||||
+3
-91
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { JsonContains, Repository } from 'typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||
import {
|
||||
@@ -10,9 +10,7 @@ import {
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
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';
|
||||
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
|
||||
import { billingValidator } from 'src/engine/core-modules/billing/billing.validate';
|
||||
|
||||
@Injectable()
|
||||
@@ -20,82 +18,9 @@ export class BillingSubscriptionItemService {
|
||||
constructor(
|
||||
@InjectRepository(BillingSubscriptionItem)
|
||||
private readonly billingSubscriptionItemRepository: Repository<BillingSubscriptionItem>,
|
||||
@InjectRepository(BillingPrice)
|
||||
private readonly billingPriceRepository: Repository<BillingPrice>,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly stripeSubscriptionService: StripeSubscriptionService,
|
||||
) {}
|
||||
|
||||
async updateMeteredSubscriptionItemPrice(
|
||||
subscriptionId: string,
|
||||
newPriceId: string,
|
||||
) {
|
||||
const subscriptionItem =
|
||||
await this.billingSubscriptionItemRepository.findOne({
|
||||
where: {
|
||||
billingSubscriptionId: subscriptionId,
|
||||
billingProduct: {
|
||||
metadata: JsonContains({
|
||||
priceUsageBased: BillingUsageType.METERED,
|
||||
}),
|
||||
},
|
||||
},
|
||||
relations: ['billingProduct', 'billingProduct.billingPrices'],
|
||||
});
|
||||
|
||||
if (!subscriptionItem) {
|
||||
throw new BillingException(
|
||||
`Cannot find subscription item for subscription ${subscriptionId}`,
|
||||
BillingExceptionCode.BILLING_SUBSCRIPTION_ITEM_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const currentBillingPrice = subscriptionItem
|
||||
? this.findMatchingPrice(subscriptionItem)
|
||||
: null;
|
||||
|
||||
if (!currentBillingPrice) {
|
||||
throw new BillingException(
|
||||
`Cannot find price for product ${subscriptionItem.stripeProductId}`,
|
||||
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const newPrice = await this.billingPriceRepository.findOne({
|
||||
where: { stripePriceId: newPriceId },
|
||||
});
|
||||
|
||||
if (!newPrice) {
|
||||
throw new BillingException(
|
||||
`Cannot find price with id ${newPriceId}`,
|
||||
BillingExceptionCode.BILLING_PRICE_NOT_FOUND,
|
||||
{ userFriendlyMessage: 'Price not found' },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!this.isFirstPriceTiersLowerThatSecondPriceTier(
|
||||
currentBillingPrice,
|
||||
newPrice,
|
||||
)
|
||||
) {
|
||||
throw new BillingException(
|
||||
'Cannot update price of subscription item because the new tier is lower than the current tier.',
|
||||
BillingExceptionCode.BILLING_PRICE_UPDATE_REQUIRES_INCREASE,
|
||||
);
|
||||
}
|
||||
|
||||
await this.stripeSubscriptionService.updateSubscriptionItems(
|
||||
subscriptionItem.stripeSubscriptionId,
|
||||
[
|
||||
{
|
||||
...subscriptionItem,
|
||||
stripePriceId: newPriceId,
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async getMeteredSubscriptionItemDetails(subscriptionId: string) {
|
||||
const meteredSubscriptionItems =
|
||||
await this.billingSubscriptionItemRepository.find({
|
||||
@@ -133,16 +58,6 @@ export class BillingSubscriptionItemService {
|
||||
);
|
||||
}
|
||||
|
||||
private isFirstPriceTiersLowerThatSecondPriceTier(
|
||||
price1: BillingPrice,
|
||||
price2: BillingPrice,
|
||||
) {
|
||||
billingValidator.assertIsMeteredTiersSchemaOrThrow(price1.tiers);
|
||||
billingValidator.assertIsMeteredTiersSchemaOrThrow(price2.tiers);
|
||||
|
||||
return price1.tiers[0].up_to < price2.tiers[0].up_to;
|
||||
}
|
||||
|
||||
private findMatchingPrice(item: BillingSubscriptionItem): BillingPrice {
|
||||
const matchingPrice = item.billingProduct.billingPrices.find(
|
||||
(price) => price.stripePriceId === item.stripePriceId,
|
||||
@@ -167,11 +82,8 @@ export class BillingSubscriptionItemService {
|
||||
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',
|
||||
)
|
||||
return this.twentyConfigService.get(
|
||||
'BILLING_FREE_WORKFLOW_CREDITS_FOR_TRIAL_PERIOD_WITHOUT_CREDIT_CARD',
|
||||
);
|
||||
default:
|
||||
return 0;
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import {
|
||||
assertIsDefinedOrThrow,
|
||||
findOrThrow,
|
||||
isDefined,
|
||||
} from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { BillingSubscriptionSchedulePhase } from 'src/engine/core-modules/billing/dtos/billing-subscription-schedule-phase.dto';
|
||||
import { BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||
import { BillingPlanService } from 'src/engine/core-modules/billing/services/billing-plan.service';
|
||||
import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils';
|
||||
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
|
||||
@Injectable()
|
||||
export class BillingSubscriptionPhaseService {
|
||||
constructor(
|
||||
@InjectRepository(BillingPrice)
|
||||
private readonly billingPriceRepository: Repository<BillingPrice>,
|
||||
private readonly billingPlanService: BillingPlanService,
|
||||
) {}
|
||||
|
||||
async getDetailsFromPhase(phase: BillingSubscriptionSchedulePhase) {
|
||||
const meteredPrice = await this.billingPriceRepository.findOneByOrFail({
|
||||
stripePriceId: findOrThrow(
|
||||
phase.items,
|
||||
({ quantity }) => !isDefined(quantity),
|
||||
).price,
|
||||
});
|
||||
|
||||
const { quantity, price: licensedItemPriceId } = findOrThrow(
|
||||
phase.items,
|
||||
({ quantity }) => isDefined(quantity),
|
||||
);
|
||||
|
||||
const licensedPrice = await this.billingPriceRepository.findOneByOrFail({
|
||||
stripePriceId: licensedItemPriceId,
|
||||
});
|
||||
|
||||
const plan = await this.billingPlanService.getPlanByPriceId(
|
||||
meteredPrice.stripePriceId,
|
||||
);
|
||||
|
||||
if (!isDefined(quantity)) {
|
||||
throw new Error('Quantity is not defined');
|
||||
}
|
||||
|
||||
return {
|
||||
plan,
|
||||
meteredPrice,
|
||||
licensedPrice,
|
||||
quantity,
|
||||
interval: meteredPrice.interval,
|
||||
};
|
||||
}
|
||||
|
||||
toSnapshot(
|
||||
phase: Stripe.SubscriptionSchedule.Phase,
|
||||
): Stripe.SubscriptionScheduleUpdateParams.Phase {
|
||||
return {
|
||||
start_date: phase.start_date,
|
||||
end_date: phase.end_date ?? undefined,
|
||||
items: (phase.items || []).map((it) => ({
|
||||
price: normalizePriceRef(it.price) as string,
|
||||
quantity: it.quantity ?? undefined,
|
||||
})),
|
||||
...(phase.billing_thresholds
|
||||
? { billing_thresholds: phase.billing_thresholds }
|
||||
: {}),
|
||||
proration_behavior: 'none',
|
||||
} as Stripe.SubscriptionScheduleUpdateParams.Phase;
|
||||
}
|
||||
|
||||
buildSnapshot(
|
||||
base: Stripe.SubscriptionScheduleUpdateParams.Phase,
|
||||
licensedPriceId: string,
|
||||
seats: number,
|
||||
meteredPriceId: string,
|
||||
billing_thresholds?: Stripe.SubscriptionScheduleUpdateParams.Phase.BillingThresholds,
|
||||
): Stripe.SubscriptionScheduleUpdateParams.Phase {
|
||||
return {
|
||||
start_date: base.start_date,
|
||||
end_date: base.end_date,
|
||||
proration_behavior: base.proration_behavior ?? 'none',
|
||||
items: [
|
||||
{ price: licensedPriceId, quantity: seats },
|
||||
{ price: meteredPriceId },
|
||||
],
|
||||
...(billing_thresholds ? { billing_thresholds } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
getLicensedPriceIdFromSnapshot(
|
||||
phase: Stripe.SubscriptionScheduleUpdateParams.Phase,
|
||||
): string {
|
||||
const licensedItem = findOrThrow(phase.items!, (i) => i.quantity != null);
|
||||
|
||||
assertIsDefinedOrThrow(licensedItem.price);
|
||||
|
||||
return licensedItem.price;
|
||||
}
|
||||
|
||||
async isSamePhaseSignature(
|
||||
a: Stripe.SubscriptionScheduleUpdateParams.Phase,
|
||||
b: Stripe.SubscriptionScheduleUpdateParams.Phase,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const sigA = await this.getPhaseSignatureFromSnapshot(a);
|
||||
const sigB = await this.getPhaseSignatureFromSnapshot(b);
|
||||
|
||||
return (
|
||||
sigA.planKey === sigB.planKey &&
|
||||
sigA.interval === sigB.interval &&
|
||||
sigA.meteredPriceId === sigB.meteredPriceId
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async getPhaseSignatureFromSnapshot(
|
||||
phase: Stripe.SubscriptionScheduleUpdateParams.Phase,
|
||||
): Promise<{
|
||||
planKey: BillingPlanKey;
|
||||
interval: SubscriptionInterval;
|
||||
meteredPriceId: string;
|
||||
}> {
|
||||
const metered = findOrThrow(phase.items!, (i) => i.quantity == null);
|
||||
const meteredPriceId = metered.price;
|
||||
|
||||
assertIsDefinedOrThrow(meteredPriceId);
|
||||
|
||||
const meteredPrice = await this.billingPriceRepository.findOneOrFail({
|
||||
where: {
|
||||
stripePriceId: meteredPriceId,
|
||||
},
|
||||
relations: ['billingProduct'],
|
||||
});
|
||||
|
||||
const plan = await this.billingPlanService.getPlanByPriceId(meteredPriceId);
|
||||
|
||||
return {
|
||||
planKey: plan.planKey,
|
||||
interval: meteredPrice.interval,
|
||||
meteredPriceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
+638
-368
File diff suppressed because it is too large
Load Diff
+1202
-193
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,6 @@ import { BillingProductService } from 'src/engine/core-modules/billing/services/
|
||||
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
|
||||
import { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/utils/get-plan-key-from-subscription.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { BillingSubscriptionItemService } from 'src/engine/core-modules/billing/services/billing-subscription-item.service';
|
||||
|
||||
@Injectable()
|
||||
export class BillingService {
|
||||
@@ -23,7 +22,6 @@ export class BillingService {
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly billingSubscriptionService: BillingSubscriptionService,
|
||||
private readonly billingProductService: BillingProductService,
|
||||
private readonly billingSubscriptionItemService: BillingSubscriptionItemService,
|
||||
@InjectRepository(BillingSubscription)
|
||||
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
|
||||
) {}
|
||||
@@ -69,40 +67,6 @@ export class BillingService {
|
||||
return !hasAnySubscription;
|
||||
}
|
||||
|
||||
async updateMeteredSubscriptionPrice(workspaceId: string, priceId: string) {
|
||||
const subscription =
|
||||
await this.billingSubscriptionService.getCurrentActiveBillingSubscriptionOrThrow(
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
await this.billingSubscriptionItemService.updateMeteredSubscriptionItemPrice(
|
||||
subscription.id,
|
||||
priceId,
|
||||
);
|
||||
}
|
||||
|
||||
async listMeteredBillingPricesByWorkspaceIdAndProductKey(
|
||||
workspaceId: string,
|
||||
productKey: BillingProductKey = BillingProductKey.WORKFLOW_NODE_EXECUTION,
|
||||
) {
|
||||
const subscription =
|
||||
await this.billingSubscriptionService.getCurrentActiveBillingSubscriptionOrThrow(
|
||||
{ workspaceId },
|
||||
);
|
||||
const planKey = getPlanKeyFromSubscription(subscription);
|
||||
const products =
|
||||
await this.billingProductService.getProductsByPlan(planKey);
|
||||
const targetProduct = products.find(
|
||||
({ metadata }) => metadata.productKey === productKey,
|
||||
);
|
||||
|
||||
return (
|
||||
targetProduct?.billingPrices.filter(
|
||||
({ active, interval }) => active && interval === subscription.interval,
|
||||
) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
async canBillMeteredProduct(
|
||||
workspaceId: string,
|
||||
productKey: BillingProductKey,
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
|
||||
import { StripeBillingMeterService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-meter.service';
|
||||
|
||||
@Injectable()
|
||||
export class StripeBillingAlertService {
|
||||
protected readonly logger = new Logger(StripeBillingAlertService.name);
|
||||
private readonly stripe: Stripe;
|
||||
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly stripeSDKService: StripeSDKService,
|
||||
private readonly stripeBillingMeterService: StripeBillingMeterService,
|
||||
) {
|
||||
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
|
||||
return;
|
||||
}
|
||||
this.stripe = this.stripeSDKService.getStripe(
|
||||
this.twentyConfigService.get('BILLING_STRIPE_API_KEY'),
|
||||
);
|
||||
}
|
||||
|
||||
async createUsageThresholdAlertForCustomerMeter(
|
||||
customerId: string,
|
||||
gte: number,
|
||||
): Promise<void> {
|
||||
const meters = await this.stripeBillingMeterService.getAllMeters();
|
||||
|
||||
await this.stripe.billing.alerts.create({
|
||||
alert_type: 'usage_threshold',
|
||||
title: `Trial usage cap for customer ${customerId}`,
|
||||
usage_threshold: {
|
||||
gte,
|
||||
meter: meters[0].id,
|
||||
recurrence: 'one_time',
|
||||
filters: [
|
||||
{
|
||||
type: 'customer',
|
||||
customer: customerId,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
-6
@@ -95,7 +95,6 @@ export class StripeCheckoutService {
|
||||
stripeCustomerId,
|
||||
plan = BillingPlanKey.PRO,
|
||||
requirePaymentMethod = false,
|
||||
withTrialPeriod,
|
||||
}: {
|
||||
user: User;
|
||||
workspace: Pick<Workspace, 'id' | 'displayName'>;
|
||||
@@ -103,7 +102,6 @@ export class StripeCheckoutService {
|
||||
stripeCustomerId?: string;
|
||||
plan?: BillingPlanKey;
|
||||
requirePaymentMethod?: boolean;
|
||||
withTrialPeriod: boolean;
|
||||
}): Promise<Stripe.Subscription> {
|
||||
if (!isDefined(stripeCustomerId)) {
|
||||
const stripeCustomer =
|
||||
@@ -130,10 +128,6 @@ export class StripeCheckoutService {
|
||||
workspaceId: workspace.id,
|
||||
plan,
|
||||
},
|
||||
...this.getStripeSubscriptionTrialPeriodConfig(
|
||||
withTrialPeriod,
|
||||
requirePaymentMethod,
|
||||
),
|
||||
automatic_tax: { enabled: !!requirePaymentMethod },
|
||||
};
|
||||
|
||||
|
||||
+7
@@ -24,10 +24,17 @@ export class StripePriceService {
|
||||
);
|
||||
}
|
||||
|
||||
async getPriceByPriceId(priceId: string) {
|
||||
return await this.stripe.prices.retrieve(priceId, {
|
||||
expand: ['data.currency_options', 'data.tiers'],
|
||||
});
|
||||
}
|
||||
|
||||
async getPricesByProductId(productId: string) {
|
||||
const prices = await this.stripe.prices.list({
|
||||
product: productId,
|
||||
type: 'recurring',
|
||||
limit: 100,
|
||||
expand: ['data.currency_options', 'data.tiers'],
|
||||
});
|
||||
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { SubscriptionWithSchedule } from 'src/engine/core-modules/billing/types/billing-subscription-with-schedule.type';
|
||||
import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils';
|
||||
|
||||
@Injectable()
|
||||
export class StripeSubscriptionScheduleService {
|
||||
protected readonly logger = new Logger(
|
||||
StripeSubscriptionScheduleService.name,
|
||||
);
|
||||
private readonly stripe: Stripe;
|
||||
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly stripeSDKService: StripeSDKService,
|
||||
) {
|
||||
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
|
||||
return;
|
||||
}
|
||||
this.stripe = this.stripeSDKService.getStripe(
|
||||
this.twentyConfigService.get('BILLING_STRIPE_API_KEY'),
|
||||
);
|
||||
}
|
||||
|
||||
private snapshotFromLivePhase(phase: Stripe.SubscriptionSchedule.Phase) {
|
||||
return {
|
||||
start_date: phase.start_date,
|
||||
end_date: phase.end_date ?? undefined,
|
||||
items: (phase.items || []).map((i) => ({
|
||||
price: normalizePriceRef(i.price) as string,
|
||||
quantity: i.quantity ?? undefined,
|
||||
})),
|
||||
proration_behavior: 'none',
|
||||
...(phase.billing_thresholds
|
||||
? { billing_thresholds: phase.billing_thresholds }
|
||||
: {}),
|
||||
} as Stripe.SubscriptionScheduleUpdateParams.Phase;
|
||||
}
|
||||
|
||||
private computeBaseStart(
|
||||
currentEditable: Stripe.SubscriptionSchedule.Phase | undefined,
|
||||
nextEditable: Stripe.SubscriptionSchedule.Phase | undefined,
|
||||
live: Stripe.SubscriptionSchedule,
|
||||
now: number,
|
||||
): number {
|
||||
const cEnd = (currentEditable?.end_date as number | undefined) ?? 0;
|
||||
const curPhaseEnd =
|
||||
(live.current_phase?.end_date as number | undefined) ?? 0;
|
||||
const nStart = (nextEditable?.start_date as number | undefined) ?? 0;
|
||||
|
||||
return Math.max(cEnd, curPhaseEnd, nStart, now + 1);
|
||||
}
|
||||
|
||||
getEditablePhases(live: Stripe.SubscriptionSchedule) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const currentEditable = (live.phases || []).find((p) => {
|
||||
const s = p.start_date ?? 0;
|
||||
const e = p.end_date ?? Infinity;
|
||||
|
||||
return s <= now && now < e;
|
||||
});
|
||||
|
||||
const nextEditable = (live.phases || [])
|
||||
.filter((p) => (p.start_date ?? 0) > now)
|
||||
.sort((a, b) => (a.start_date ?? 0) - (b.start_date ?? 0))[0];
|
||||
|
||||
return { currentEditable, nextEditable };
|
||||
}
|
||||
|
||||
async getSubscriptionWithSchedule(stripeSubscriptionId: string) {
|
||||
return (await this.stripe.subscriptions.retrieve(stripeSubscriptionId, {
|
||||
expand: ['schedule'],
|
||||
})) as SubscriptionWithSchedule;
|
||||
}
|
||||
|
||||
async retrieveSchedule(scheduleId: string) {
|
||||
if (!this.stripe) throw new Error('Billing is disabled');
|
||||
|
||||
return this.stripe.subscriptionSchedules.retrieve(scheduleId, {
|
||||
expand: ['subscription'],
|
||||
});
|
||||
}
|
||||
|
||||
async updateSchedule(
|
||||
scheduleId: string,
|
||||
params: Stripe.SubscriptionScheduleUpdateParams,
|
||||
) {
|
||||
if (!this.stripe) throw new Error('Billing is disabled');
|
||||
|
||||
return this.stripe.subscriptionSchedules.update(scheduleId, params);
|
||||
}
|
||||
|
||||
async createScheduleFromSubscription(subscriptionId: string) {
|
||||
if (!this.stripe) throw new Error('Billing is disabled');
|
||||
|
||||
return this.stripe.subscriptionSchedules.create({
|
||||
from_subscription: subscriptionId,
|
||||
});
|
||||
}
|
||||
|
||||
async findOrCreateSubscriptionSchedule(
|
||||
subscription: SubscriptionWithSchedule,
|
||||
) {
|
||||
if (subscription.schedule) return subscription.schedule;
|
||||
|
||||
return this.createScheduleFromSubscription(subscription.id);
|
||||
}
|
||||
|
||||
async replaceEditablePhases(
|
||||
scheduleId: string,
|
||||
desired: {
|
||||
currentSnapshot?: Stripe.SubscriptionScheduleUpdateParams.Phase;
|
||||
nextPhase?: Stripe.SubscriptionScheduleUpdateParams.Phase;
|
||||
},
|
||||
): Promise<Stripe.SubscriptionSchedule> {
|
||||
if (!this.stripe) throw new Error('Billing is disabled');
|
||||
|
||||
const live = await this.retrieveSchedule(scheduleId);
|
||||
const { currentEditable, nextEditable } = this.getEditablePhases(live);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const phases: Stripe.SubscriptionScheduleUpdateParams.Phase[] = [];
|
||||
|
||||
if (currentEditable) {
|
||||
const currentSnapshot =
|
||||
desired.currentSnapshot ?? this.snapshotFromLivePhase(currentEditable);
|
||||
|
||||
phases.push(currentSnapshot);
|
||||
}
|
||||
|
||||
const hasNextKey = 'nextPhase' in desired;
|
||||
const wantsNext = hasNextKey && !!desired.nextPhase;
|
||||
const wantsDeleteNext = hasNextKey && !desired.nextPhase;
|
||||
const preserveExistingNext = !hasNextKey && !!nextEditable;
|
||||
|
||||
if (wantsNext) {
|
||||
phases.push({
|
||||
...desired.nextPhase!,
|
||||
start_date: this.computeBaseStart(
|
||||
currentEditable,
|
||||
nextEditable,
|
||||
live,
|
||||
now,
|
||||
),
|
||||
proration_behavior: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
if (!wantsNext && !wantsDeleteNext && preserveExistingNext) {
|
||||
phases.push(this.snapshotFromLivePhase(nextEditable!));
|
||||
}
|
||||
|
||||
if (phases.length === 0 && wantsNext) {
|
||||
phases.push({
|
||||
...desired.nextPhase!,
|
||||
start_date: Math.max(
|
||||
(live.current_phase?.end_date as number | undefined) ?? 0,
|
||||
now + 1,
|
||||
),
|
||||
proration_behavior: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
if (phases.length === 0) return live;
|
||||
|
||||
return this.updateSchedule(scheduleId, { phases });
|
||||
}
|
||||
}
|
||||
+16
-11
@@ -7,6 +7,7 @@ import type Stripe from 'stripe';
|
||||
import { type BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
|
||||
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
|
||||
@Injectable()
|
||||
export class StripeSubscriptionService {
|
||||
@@ -34,11 +35,10 @@ export class StripeSubscriptionService {
|
||||
query: `metadata['workspaceId']:'${workspaceId}'`,
|
||||
limit: 1,
|
||||
});
|
||||
const stripeCustomerId = subscription.data[0].customer
|
||||
? String(subscription.data[0].customer)
|
||||
: undefined;
|
||||
|
||||
return stripeCustomerId;
|
||||
return subscription.data[0].customer
|
||||
? subscription.data[0].customer
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async collectLastInvoice(stripeSubscriptionId: string) {
|
||||
@@ -84,15 +84,20 @@ export class StripeSubscriptionService {
|
||||
return this.stripe.subscriptions.update(stripeSubscriptionId, updateData);
|
||||
}
|
||||
|
||||
getBillingThresholdsByInterval(interval: SubscriptionInterval) {
|
||||
return {
|
||||
amount_gte:
|
||||
this.twentyConfigService.get('BILLING_SUBSCRIPTION_THRESHOLD_AMOUNT') *
|
||||
(interval === SubscriptionInterval.Year ? 12 : 1),
|
||||
reset_billing_cycle_anchor: false,
|
||||
};
|
||||
}
|
||||
|
||||
async setYearlyThresholds(stripeSubscriptionId: string) {
|
||||
return this.stripe.subscriptions.update(stripeSubscriptionId, {
|
||||
billing_thresholds: {
|
||||
amount_gte:
|
||||
this.twentyConfigService.get(
|
||||
'BILLING_SUBSCRIPTION_THRESHOLD_AMOUNT',
|
||||
) * 12,
|
||||
reset_billing_cycle_anchor: false,
|
||||
},
|
||||
billing_thresholds: this.getBillingThresholdsByInterval(
|
||||
SubscriptionInterval.Year,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ import { StripePriceService } from 'src/engine/core-modules/billing/stripe/servi
|
||||
import { StripeProductService } from 'src/engine/core-modules/billing/stripe/services/stripe-product.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 { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
|
||||
import { StripeWebhookService } from 'src/engine/core-modules/billing/stripe/services/stripe-webhook.service';
|
||||
import { StripeSDKModule } from 'src/engine/core-modules/billing/stripe/stripe-sdk/stripe-sdk.module';
|
||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||
import { StripeBillingAlertService } from 'src/engine/core-modules/billing/stripe/services/stripe-billing-alert.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -28,12 +30,14 @@ import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/doma
|
||||
StripeWebhookService,
|
||||
StripeCheckoutService,
|
||||
StripeSubscriptionService,
|
||||
StripeSubscriptionScheduleService,
|
||||
StripeBillingPortalService,
|
||||
StripeBillingMeterService,
|
||||
StripeCustomerService,
|
||||
StripePriceService,
|
||||
StripeProductService,
|
||||
StripeBillingMeterEventService,
|
||||
StripeBillingAlertService,
|
||||
],
|
||||
exports: [
|
||||
StripeWebhookService,
|
||||
@@ -46,6 +50,8 @@ import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/doma
|
||||
StripeSubscriptionService,
|
||||
StripeProductService,
|
||||
StripeBillingMeterEventService,
|
||||
StripeSubscriptionScheduleService,
|
||||
StripeBillingAlertService,
|
||||
],
|
||||
})
|
||||
export class StripeModule {}
|
||||
|
||||
+1
-2
@@ -5,7 +5,6 @@ import { type BillingPlanKey } from 'src/engine/core-modules/billing/enums/billi
|
||||
|
||||
export type BillingGetPlanResult = {
|
||||
planKey: BillingPlanKey;
|
||||
baseProduct: BillingProduct;
|
||||
meteredProducts: BillingProduct[];
|
||||
otherLicensedProducts: BillingProduct[];
|
||||
licensedProducts: BillingProduct[];
|
||||
};
|
||||
|
||||
+1
-2
@@ -3,7 +3,6 @@
|
||||
import { type BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||
|
||||
export type BillingGetPricesPerPlanResult = {
|
||||
baseProductPrice: BillingPrice;
|
||||
meteredProductsPrices: BillingPrice[];
|
||||
otherLicensedProductsPrices: BillingPrice[];
|
||||
licensedProductsPrices: BillingPrice[];
|
||||
};
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import type { MeterBillingPriceTiers } from 'src/engine/core-modules/billing/types/meter-billing-price-tier.type';
|
||||
import { type BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||
|
||||
export type BillingMeterPrice = BillingPrice & {
|
||||
tiers: MeterBillingPriceTiers;
|
||||
};
|
||||
+1
-1
@@ -8,7 +8,7 @@ import { type Workspace } from 'src/engine/core-modules/workspace/workspace.enti
|
||||
export type BillingPortalCheckoutSessionParameters = {
|
||||
user: User;
|
||||
workspace: Workspace;
|
||||
billingPricesPerPlan?: BillingGetPricesPerPlanResult;
|
||||
billingPricesPerPlan: BillingGetPricesPerPlanResult;
|
||||
successUrlPath?: string;
|
||||
plan: BillingPlanKey;
|
||||
requirePaymentMethod?: boolean;
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { type BillingSubscriptionItem } from 'src/engine/core-modules/billing/entities/billing-subscription-item.entity';
|
||||
|
||||
export type LicensedBillingSubscriptionItem = Omit<
|
||||
BillingSubscriptionItem,
|
||||
'quantity'
|
||||
> & {
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
export type MeteredBillingSubscriptionItem = Omit<
|
||||
BillingSubscriptionItem,
|
||||
'quantity'
|
||||
> & {
|
||||
quantity: null;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
export type SubscriptionWithSchedule = Omit<Stripe.Subscription, 'schedule'> & {
|
||||
schedule: Stripe.SubscriptionSchedule;
|
||||
};
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { type BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
|
||||
import {
|
||||
type LicensedBillingSubscriptionItem,
|
||||
type MeteredBillingSubscriptionItem,
|
||||
} from 'src/engine/core-modules/billing/types/billing-subscription-item.type';
|
||||
|
||||
export type BillingSubscriptionWithSubscriptionItems = Omit<
|
||||
BillingSubscription,
|
||||
'billingSubscriptionItems'
|
||||
> & {
|
||||
billingSubscriptionItems: Array<
|
||||
LicensedBillingSubscriptionItem | MeteredBillingSubscriptionItem
|
||||
>;
|
||||
};
|
||||
+2
-2
@@ -2,9 +2,9 @@ export type MeterBillingPriceTiers = [
|
||||
{
|
||||
up_to: number;
|
||||
flat_amount: number;
|
||||
unit_amount: number;
|
||||
unit_amount: null;
|
||||
flat_amount_decimal: string;
|
||||
unit_amount_decimal: string;
|
||||
unit_amount_decimal: null;
|
||||
},
|
||||
{
|
||||
up_to: null;
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { ensureFutureStartDate } from 'src/engine/core-modules/billing/utils/ensure-future-start-date.util';
|
||||
|
||||
describe('ensureFutureStartDate', () => {
|
||||
const fixedNowSec = 1_700_000_000; // arbitrary fixed epoch seconds
|
||||
const fixedNowMs = fixedNowSec * 1000;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(Date, 'now').mockReturnValue(fixedNowMs);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns now+1 when called with no arguments', () => {
|
||||
expect(ensureFutureStartDate()).toBe(fixedNowSec + 1);
|
||||
});
|
||||
|
||||
it('returns now+1 when all provided dates are in the past or equal to now', () => {
|
||||
const past = fixedNowSec - 10;
|
||||
const equal = fixedNowSec;
|
||||
|
||||
expect(ensureFutureStartDate(past, null, undefined, 0, equal)).toBe(
|
||||
fixedNowSec + 1,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the maximum future date when at least one future date is provided', () => {
|
||||
const future1 = fixedNowSec + 5;
|
||||
const future2 = fixedNowSec + 10;
|
||||
|
||||
expect(ensureFutureStartDate(future1, future2)).toBe(future2);
|
||||
});
|
||||
|
||||
it('ignores null/undefined by treating them as 0 and still enforces at least now+1', () => {
|
||||
const result = ensureFutureStartDate(
|
||||
null,
|
||||
undefined,
|
||||
-100,
|
||||
fixedNowSec - 1,
|
||||
);
|
||||
|
||||
expect(result).toBe(fixedNowSec + 1);
|
||||
});
|
||||
|
||||
it('returns a provided future date even if it is just one second above now+1 boundary', () => {
|
||||
const barelyFuture = fixedNowSec + 2; // > now+1
|
||||
|
||||
expect(ensureFutureStartDate(barelyFuture)).toBe(barelyFuture);
|
||||
});
|
||||
});
|
||||
+35
-180
@@ -1,37 +1,23 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum';
|
||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||
import { type BillingGetPlanResult } from 'src/engine/core-modules/billing/types/billing-get-plan-result.type';
|
||||
import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util';
|
||||
import type { BillingGetPlanResult } from 'src/engine/core-modules/billing/types/billing-get-plan-result.type';
|
||||
|
||||
describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
||||
it('should format a complete billing plan correctly', () => {
|
||||
it('should correctly format a billing plan with licensed and metered products', () => {
|
||||
const mockPlan = {
|
||||
planKey: BillingPlanKey.PRO,
|
||||
baseProduct: {
|
||||
id: 'base-1',
|
||||
name: 'Base Product',
|
||||
billingPrices: [
|
||||
{
|
||||
interval: SubscriptionInterval.Month,
|
||||
unitAmount: 1000,
|
||||
stripePriceId: 'price_base1',
|
||||
priceUsageType: BillingUsageType.LICENSED,
|
||||
},
|
||||
],
|
||||
},
|
||||
otherLicensedProducts: [
|
||||
licensedProducts: [
|
||||
{
|
||||
id: 'licensed-1',
|
||||
name: 'Licensed Product',
|
||||
id: 'product-1',
|
||||
name: 'Test Licensed Product',
|
||||
billingPrices: [
|
||||
{
|
||||
interval: SubscriptionInterval.Year,
|
||||
unitAmount: 2000,
|
||||
stripePriceId: 'price_licensed1',
|
||||
interval: SubscriptionInterval.Month,
|
||||
unitAmount: 1500,
|
||||
stripePriceId: 'price_123',
|
||||
priceUsageType: BillingUsageType.LICENSED,
|
||||
},
|
||||
],
|
||||
@@ -39,18 +25,14 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
||||
],
|
||||
meteredProducts: [
|
||||
{
|
||||
id: 'metered-1',
|
||||
name: 'Metered Product',
|
||||
id: 'product-2',
|
||||
name: 'Test Metered Product',
|
||||
billingPrices: [
|
||||
{
|
||||
interval: SubscriptionInterval.Month,
|
||||
tiersMode: BillingPriceTiersMode.GRADUATED,
|
||||
tiers: [
|
||||
{
|
||||
up_to: 10,
|
||||
flat_amount: 1000,
|
||||
unit_amount: 100,
|
||||
},
|
||||
{ up_to: 10, flat_amount: 500, unit_amount: null },
|
||||
{ up_to: null, flat_amount: null, unit_amount: 0.001 },
|
||||
],
|
||||
stripePriceId: 'price_metered1',
|
||||
priceUsageType: BillingUsageType.METERED,
|
||||
@@ -66,181 +48,54 @@ describe('formatBillingDatabaseProductToGraphqlDTO', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
planKey: BillingPlanKey.PRO,
|
||||
baseProduct: {
|
||||
id: 'base-1',
|
||||
metadata: {
|
||||
priceUsageBased: BillingUsageType.LICENSED,
|
||||
},
|
||||
name: 'Base Product',
|
||||
billingPrices: [
|
||||
{
|
||||
interval: SubscriptionInterval.Month,
|
||||
unitAmount: 1000,
|
||||
stripePriceId: 'price_base1',
|
||||
priceUsageType: BillingUsageType.LICENSED,
|
||||
},
|
||||
],
|
||||
prices: [
|
||||
{
|
||||
recurringInterval: SubscriptionInterval.Month,
|
||||
unitAmount: 1000,
|
||||
stripePriceId: 'price_base1',
|
||||
priceUsageType: BillingUsageType.LICENSED,
|
||||
},
|
||||
],
|
||||
},
|
||||
otherLicensedProducts: [
|
||||
licensedProducts: [
|
||||
{
|
||||
id: 'licensed-1',
|
||||
metadata: {
|
||||
priceUsageBased: BillingUsageType.LICENSED,
|
||||
},
|
||||
name: 'Licensed Product',
|
||||
billingPrices: [
|
||||
{
|
||||
interval: SubscriptionInterval.Year,
|
||||
unitAmount: 2000,
|
||||
stripePriceId: 'price_licensed1',
|
||||
priceUsageType: BillingUsageType.LICENSED,
|
||||
},
|
||||
],
|
||||
prices: [
|
||||
{
|
||||
recurringInterval: SubscriptionInterval.Year,
|
||||
unitAmount: 2000,
|
||||
stripePriceId: 'price_licensed1',
|
||||
priceUsageType: BillingUsageType.LICENSED,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
meteredProducts: [
|
||||
{
|
||||
id: 'metered-1',
|
||||
metadata: {
|
||||
priceUsageBased: BillingUsageType.METERED,
|
||||
},
|
||||
name: 'Metered Product',
|
||||
id: 'product-1',
|
||||
name: 'Test Licensed Product',
|
||||
billingPrices: [
|
||||
{
|
||||
interval: SubscriptionInterval.Month,
|
||||
tiersMode: BillingPriceTiersMode.GRADUATED,
|
||||
tiers: [
|
||||
{
|
||||
up_to: 10,
|
||||
flat_amount: 1000,
|
||||
unit_amount: 100,
|
||||
},
|
||||
],
|
||||
stripePriceId: 'price_metered1',
|
||||
priceUsageType: BillingUsageType.METERED,
|
||||
unitAmount: 1500,
|
||||
stripePriceId: 'price_123',
|
||||
priceUsageType: BillingUsageType.LICENSED,
|
||||
},
|
||||
],
|
||||
prices: [
|
||||
{
|
||||
tiersMode: BillingPriceTiersMode.GRADUATED,
|
||||
tiers: [
|
||||
{
|
||||
upTo: 10,
|
||||
flatAmount: 1000,
|
||||
unitAmount: 100,
|
||||
},
|
||||
],
|
||||
recurringInterval: SubscriptionInterval.Month,
|
||||
stripePriceId: 'price_metered1',
|
||||
priceUsageType: BillingUsageType.METERED,
|
||||
unitAmount: 1500,
|
||||
stripePriceId: 'price_123',
|
||||
priceUsageType: BillingUsageType.LICENSED,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty products and null values', () => {
|
||||
const mockPlan = {
|
||||
planKey: 'empty-plan',
|
||||
baseProduct: {
|
||||
id: 'base-1',
|
||||
name: 'Base Product',
|
||||
billingPrices: [
|
||||
{
|
||||
interval: null,
|
||||
unitAmount: null,
|
||||
stripePriceId: null,
|
||||
priceUsageType: BillingUsageType.LICENSED,
|
||||
},
|
||||
],
|
||||
},
|
||||
otherLicensedProducts: [],
|
||||
meteredProducts: [
|
||||
{
|
||||
id: 'metered-1',
|
||||
name: 'Metered Product',
|
||||
billingPrices: [
|
||||
{
|
||||
interval: null,
|
||||
tiersMode: null,
|
||||
tiers: null,
|
||||
stripePriceId: null,
|
||||
priceUsageType: BillingUsageType.METERED,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = formatBillingDatabaseProductToGraphqlDTO(
|
||||
mockPlan as unknown as BillingGetPlanResult,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
planKey: 'empty-plan',
|
||||
baseProduct: {
|
||||
id: 'base-1',
|
||||
metadata: {
|
||||
priceUsageBased: BillingUsageType.LICENSED,
|
||||
},
|
||||
name: 'Base Product',
|
||||
billingPrices: [
|
||||
{
|
||||
interval: null,
|
||||
unitAmount: null,
|
||||
stripePriceId: null,
|
||||
priceUsageType: BillingUsageType.LICENSED,
|
||||
},
|
||||
],
|
||||
prices: [
|
||||
{
|
||||
recurringInterval: SubscriptionInterval.Month,
|
||||
unitAmount: 0,
|
||||
stripePriceId: null,
|
||||
priceUsageType: BillingUsageType.LICENSED,
|
||||
},
|
||||
],
|
||||
},
|
||||
otherLicensedProducts: [],
|
||||
meteredProducts: [
|
||||
{
|
||||
id: 'metered-1',
|
||||
id: 'product-2',
|
||||
metadata: {
|
||||
priceUsageBased: BillingUsageType.METERED,
|
||||
priceUsageBased: 'METERED',
|
||||
},
|
||||
name: 'Metered Product',
|
||||
name: 'Test Metered Product',
|
||||
billingPrices: [
|
||||
{
|
||||
interval: null,
|
||||
tiersMode: null,
|
||||
tiers: null,
|
||||
stripePriceId: null,
|
||||
interval: SubscriptionInterval.Month,
|
||||
tiers: [
|
||||
{ up_to: 10, flat_amount: 500, unit_amount: null },
|
||||
{ up_to: null, flat_amount: null, unit_amount: 0.001 },
|
||||
],
|
||||
stripePriceId: 'price_metered1',
|
||||
priceUsageType: BillingUsageType.METERED,
|
||||
},
|
||||
],
|
||||
prices: [
|
||||
{
|
||||
tiersMode: null,
|
||||
tiers: [],
|
||||
tiers: [
|
||||
{ upTo: 10, flatAmount: 500, unitAmount: null },
|
||||
{ upTo: null, flatAmount: null, unitAmount: 0.001 },
|
||||
],
|
||||
recurringInterval: SubscriptionInterval.Month,
|
||||
stripePriceId: null,
|
||||
stripePriceId: 'price_metered1',
|
||||
priceUsageType: BillingUsageType.METERED,
|
||||
},
|
||||
],
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { normalizePriceRef } from 'src/engine/core-modules/billing/utils/normalize-price-ref.utils';
|
||||
|
||||
describe('normalizePriceRef', () => {
|
||||
it('returns the same string when input is a string id', () => {
|
||||
expect(normalizePriceRef('price_123')).toBe('price_123');
|
||||
});
|
||||
|
||||
it('returns the id field when input is an object with id', () => {
|
||||
expect(normalizePriceRef({ id: 'price_abc' })).toBe('price_abc');
|
||||
});
|
||||
|
||||
it('returns undefined for null input', () => {
|
||||
expect(normalizePriceRef(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for undefined input', () => {
|
||||
expect(normalizePriceRef(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves empty string ids', () => {
|
||||
expect(normalizePriceRef({ id: '' })).toBe('');
|
||||
});
|
||||
});
|
||||
-30
@@ -4,7 +4,6 @@ import type Stripe from 'stripe';
|
||||
|
||||
import { BillingPriceBillingScheme } from 'src/engine/core-modules/billing/enums/billing-price-billing-scheme.enum';
|
||||
import { BillingPriceTaxBehavior } from 'src/engine/core-modules/billing/enums/billing-price-tax-behavior.enum';
|
||||
import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum';
|
||||
import { BillingPriceType } from 'src/engine/core-modules/billing/enums/billing-price-type.enum';
|
||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||
@@ -30,7 +29,6 @@ describe('transformStripePriceToDatabasePrice', () => {
|
||||
},
|
||||
currency_options: null,
|
||||
tiers: null,
|
||||
tiers_mode: null,
|
||||
...overrides,
|
||||
}) as unknown as Stripe.Price;
|
||||
|
||||
@@ -55,7 +53,6 @@ describe('transformStripePriceToDatabasePrice', () => {
|
||||
interval: SubscriptionInterval.Month,
|
||||
currencyOptions: undefined,
|
||||
tiers: undefined,
|
||||
tiersMode: undefined,
|
||||
recurring: {
|
||||
usage_type: 'licensed',
|
||||
interval: 'month',
|
||||
@@ -130,8 +127,6 @@ describe('transformStripePriceToDatabasePrice', () => {
|
||||
|
||||
it.each([
|
||||
['month', SubscriptionInterval.Month],
|
||||
['day', SubscriptionInterval.Day],
|
||||
['week', SubscriptionInterval.Week],
|
||||
['year', SubscriptionInterval.Year],
|
||||
])('should transform interval %s correctly', (stripeInterval, expected) => {
|
||||
const mockPrice = createMockPrice({
|
||||
@@ -147,31 +142,6 @@ describe('transformStripePriceToDatabasePrice', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('tiered pricing configurations', () => {
|
||||
const mockTiers = [
|
||||
{ up_to: 10, unit_amount: 1000 },
|
||||
{ up_to: 20, unit_amount: 800 },
|
||||
];
|
||||
|
||||
it.each([
|
||||
['graduated', BillingPriceTiersMode.GRADUATED],
|
||||
['volume', BillingPriceTiersMode.VOLUME],
|
||||
])(
|
||||
'should transform tiers mode %s correctly',
|
||||
(stripeTiersMode, expected) => {
|
||||
const mockPrice = createMockPrice({
|
||||
billing_scheme: 'tiered',
|
||||
tiers: mockTiers,
|
||||
tiers_mode: stripeTiersMode as Stripe.Price.TiersMode,
|
||||
});
|
||||
const result = transformStripePriceToDatabasePrice(mockPrice);
|
||||
|
||||
expect(result.tiersMode).toBe(expected);
|
||||
expect(result.tiers).toEqual(mockTiers);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('optional fields handling', () => {
|
||||
it('should handle transform quantity configuration', () => {
|
||||
const transformQuantity = {
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export function ensureFutureStartDate(
|
||||
...dates: Array<number | undefined | null>
|
||||
): number {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
return Math.max(...dates.map((d) => d ?? 0), now + 1);
|
||||
}
|
||||
+1
-20
@@ -4,7 +4,6 @@ import { type BillingPriceLicensedDTO } from 'src/engine/core-modules/billing/dt
|
||||
import { type BillingPriceMeteredDTO } from 'src/engine/core-modules/billing/dtos/billing-price-metered.dto';
|
||||
import { type BillingPlanOutput } from 'src/engine/core-modules/billing/dtos/outputs/billing-plan.output';
|
||||
import { type BillingPrice } from 'src/engine/core-modules/billing/entities/billing-price.entity';
|
||||
import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum';
|
||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||
import { type BillingGetPlanResult } from 'src/engine/core-modules/billing/types/billing-get-plan-result.type';
|
||||
@@ -14,23 +13,9 @@ export const formatBillingDatabaseProductToGraphqlDTO = (
|
||||
): BillingPlanOutput => {
|
||||
return {
|
||||
planKey: plan.planKey,
|
||||
baseProduct: {
|
||||
...plan.baseProduct,
|
||||
metadata: {
|
||||
...plan.baseProduct.metadata,
|
||||
priceUsageBased: BillingUsageType.LICENSED,
|
||||
},
|
||||
prices: plan.baseProduct.billingPrices.map(
|
||||
formatBillingDatabasePriceToLicensedPriceDTO,
|
||||
),
|
||||
},
|
||||
otherLicensedProducts: plan.otherLicensedProducts.map((product) => {
|
||||
licensedProducts: plan.licensedProducts.map((product) => {
|
||||
return {
|
||||
...product,
|
||||
metadata: {
|
||||
...product.metadata,
|
||||
priceUsageBased: BillingUsageType.LICENSED,
|
||||
},
|
||||
prices: product.billingPrices.map(
|
||||
formatBillingDatabasePriceToLicensedPriceDTO,
|
||||
),
|
||||
@@ -55,10 +40,6 @@ const formatBillingDatabasePriceToMeteredPriceDTO = (
|
||||
billingPrice: BillingPrice,
|
||||
): BillingPriceMeteredDTO => {
|
||||
return {
|
||||
tiersMode:
|
||||
billingPrice?.tiersMode === BillingPriceTiersMode.GRADUATED
|
||||
? BillingPriceTiersMode.GRADUATED
|
||||
: null,
|
||||
tiers:
|
||||
billingPrice?.tiers?.map((tier) => ({
|
||||
upTo: tier.up_to,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
import {
|
||||
BillingException,
|
||||
BillingExceptionCode,
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
|
||||
export const getOppositeInterval = (interval: SubscriptionInterval) => {
|
||||
if (interval === SubscriptionInterval.Month) return SubscriptionInterval.Year;
|
||||
if (interval === SubscriptionInterval.Year) return SubscriptionInterval.Month;
|
||||
throw new BillingException(
|
||||
`Interval invalid`,
|
||||
BillingExceptionCode.BILLING_SUBSCRIPTION_INTERVAL_INVALID,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
BillingException,
|
||||
BillingExceptionCode,
|
||||
} from 'src/engine/core-modules/billing/billing.exception';
|
||||
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
|
||||
export const getOppositePlan = (plan: BillingPlanKey) => {
|
||||
if (plan === BillingPlanKey.PRO) return BillingPlanKey.ENTERPRISE;
|
||||
if (plan === BillingPlanKey.ENTERPRISE) return BillingPlanKey.PRO;
|
||||
throw new BillingException(
|
||||
`Plan invalid`,
|
||||
BillingExceptionCode.BILLING_PLAN_NOT_FOUND,
|
||||
);
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export function normalizePriceRef(
|
||||
p: string | { id: string } | null | undefined,
|
||||
): string | undefined {
|
||||
if (!p) return undefined;
|
||||
|
||||
return typeof p === 'string' ? p : p.id;
|
||||
}
|
||||
-18
@@ -4,7 +4,6 @@ import type Stripe from 'stripe';
|
||||
|
||||
import { BillingPriceBillingScheme } from 'src/engine/core-modules/billing/enums/billing-price-billing-scheme.enum';
|
||||
import { BillingPriceTaxBehavior } from 'src/engine/core-modules/billing/enums/billing-price-tax-behavior.enum';
|
||||
import { BillingPriceTiersMode } from 'src/engine/core-modules/billing/enums/billing-price-tiers-mode.enum';
|
||||
import { BillingPriceType } from 'src/engine/core-modules/billing/enums/billing-price-type.enum';
|
||||
import { SubscriptionInterval } from 'src/engine/core-modules/billing/enums/billing-subscription-interval.enum';
|
||||
import { BillingUsageType } from 'src/engine/core-modules/billing/enums/billing-usage-type.enum';
|
||||
@@ -36,11 +35,7 @@ export const transformStripePriceToDatabasePrice = (data: Stripe.Price) => {
|
||||
currencyOptions:
|
||||
data.currency_options === null ? undefined : data.currency_options,
|
||||
tiers: data.tiers === null ? undefined : data.tiers,
|
||||
tiersMode: data.tiers_mode
|
||||
? getBillingPriceTiersMode(data.tiers_mode)
|
||||
: undefined,
|
||||
recurring: data.recurring === null ? undefined : data.recurring,
|
||||
metadata: data.metadata,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -82,23 +77,10 @@ const getBillingPriceUsageType = (data: Stripe.Price.Recurring.UsageType) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getBillingPriceTiersMode = (data: Stripe.Price.TiersMode) => {
|
||||
switch (data) {
|
||||
case 'graduated':
|
||||
return BillingPriceTiersMode.GRADUATED;
|
||||
case 'volume':
|
||||
return BillingPriceTiersMode.VOLUME;
|
||||
}
|
||||
};
|
||||
|
||||
const getBillingPriceInterval = (data: Stripe.Price.Recurring.Interval) => {
|
||||
switch (data) {
|
||||
case 'month':
|
||||
return SubscriptionInterval.Month;
|
||||
case 'day':
|
||||
return SubscriptionInterval.Day;
|
||||
case 'week':
|
||||
return SubscriptionInterval.Week;
|
||||
case 'year':
|
||||
return SubscriptionInterval.Year;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user