From 73953901dbdd67a3d57670380e0a616d15ebfcb0 Mon Sep 17 00:00:00 2001 From: Simon Larsen Date: Fri, 4 Aug 2023 13:24:19 +0100 Subject: [PATCH] fix fmt --- CommonServer/Services/BillingService.ts | 160 ++++++++++++++---- Workers/DataMigrations/Index.ts | 2 + .../MigrateToMeteredSubscription.ts | 99 +++++++++++ 3 files changed, 226 insertions(+), 35 deletions(-) create mode 100644 Workers/DataMigrations/MigrateToMeteredSubscription.ts diff --git a/CommonServer/Services/BillingService.ts b/CommonServer/Services/BillingService.ts index 6f05795a5e..bc42bc237b 100644 --- a/CommonServer/Services/BillingService.ts +++ b/CommonServer/Services/BillingService.ts @@ -13,6 +13,8 @@ import SubscriptionStatus from 'Common/Types/Billing/SubscriptionStatus'; import BaseService from './BaseService'; import Email from 'Common/Types/Email'; +export type SubscriptionItem = Stripe.SubscriptionItem; + export interface PaymentMethod { id: string; type: string; @@ -101,6 +103,60 @@ export class BillingService extends BaseService { ); } + public async subscribeToMeteredPlan(data: { + projectId: ObjectID; + customerId: string; + serverMeteredPlans: Array; + trialDate: Date | null; + defaultPaymentMethodId?: string | undefined; + promoCode?: string | undefined; + }): Promise<{ + meteredSubscriptionId: string; + trialEndsAt: Date | null; + }> { + const meteredPlanSubscriptionParams: Stripe.SubscriptionCreateParams = { + customer: data.customerId, + + items: data.serverMeteredPlans.map( + (item: typeof ServerMeteredPlan) => { + return { + price: item.getMeteredPlan()?.getPriceId()!, + }; + } + ), + trial_end: + data.trialDate && OneUptimeDate.isInTheFuture(data.trialDate) + ? OneUptimeDate.toUnixTimestamp(data.trialDate) + : 'now', + }; + + if (data.promoCode) { + meteredPlanSubscriptionParams.coupon = data.promoCode; + } + + if (data.defaultPaymentMethodId) { + meteredPlanSubscriptionParams.default_payment_method = + data.defaultPaymentMethodId; + } + + // Create metered subscriptions + const meteredSubscription: Stripe.Response = + await this.stripe.subscriptions.create( + meteredPlanSubscriptionParams + ); + + for (const serverMeteredPlan of data.serverMeteredPlans) { + await serverMeteredPlan.updateCurrentQuantity(data.projectId, { + meteredPlanSubscriptionId: meteredSubscription.id, + }); + } + + return { + meteredSubscriptionId: meteredSubscription.id, + trialEndsAt: data.trialDate, + }; + } + public async subscribeToPlan(data: { projectId: ObjectID; customerId: string; @@ -152,25 +208,8 @@ export class BillingService extends BaseService { : 'now', }; - const meteredPlanSubscriptionParams: Stripe.SubscriptionCreateParams = { - customer: data.customerId, - - items: data.serverMeteredPlans.map( - (item: typeof ServerMeteredPlan) => { - return { - price: item.getMeteredPlan()?.getPriceId()!, - }; - } - ), - trial_end: - trialDate && data.plan.getTrialPeriod() > 0 - ? OneUptimeDate.toUnixTimestamp(trialDate) - : 'now', - }; - if (data.promoCode) { subscriptionParams.coupon = data.promoCode; - meteredPlanSubscriptionParams.coupon = data.promoCode; } if (data.defaultPaymentMethodId) { @@ -182,20 +221,17 @@ export class BillingService extends BaseService { await this.stripe.subscriptions.create(subscriptionParams); // Create metered subscriptions - const meteredSubscription: Stripe.Response = - await this.stripe.subscriptions.create( - meteredPlanSubscriptionParams - ); - - for (const serverMeteredPlan of data.serverMeteredPlans) { - await serverMeteredPlan.updateCurrentQuantity(data.projectId, { - meteredPlanSubscriptionId: meteredSubscription.id, - }); - } + const meteredSubscription: { + meteredSubscriptionId: string; + trialEndsAt: Date | null; + } = await this.subscribeToMeteredPlan({ + ...data, + trialDate, + }); return { subscriptionId: subscription.id, - meteredSubscriptionId: meteredSubscription.id, + meteredSubscriptionId: meteredSubscription.meteredSubscriptionId, trialEndsAt: trialDate && data.plan.getTrialPeriod() > 0 ? trialDate : null, }; @@ -258,7 +294,7 @@ export class BillingService extends BaseService { // check if this pricing exists const pricingExists: boolean = subscription.items.data.some( - (item: Stripe.SubscriptionItem) => { + (item: SubscriptionItem) => { return item.price?.id === meteredPlan.getPriceId(); } ); @@ -266,11 +302,9 @@ export class BillingService extends BaseService { if (pricingExists) { // update the quantity. const subscriptionItemId: string | undefined = - subscription.items.data.find( - (item: Stripe.SubscriptionItem) => { - return item.price?.id === meteredPlan.getPriceId(); - } - )?.id; + subscription.items.data.find((item: SubscriptionItem) => { + return item.price?.id === meteredPlan.getPriceId(); + })?.id; if (!subscriptionItemId) { throw new BadDataException('Subscription Item not found'); @@ -285,7 +319,7 @@ export class BillingService extends BaseService { ); } else { // add the pricing. - const subscriptionItem: Stripe.SubscriptionItem = + const subscriptionItem: SubscriptionItem = await this.stripe.subscriptionItems.create({ subscription: subscriptionId, price: meteredPlan.getPriceId(), @@ -325,6 +359,62 @@ export class BillingService extends BaseService { } } + public async removeSubscriptionItem( + subscriptionId: string, + subscriptionItemId: string, + isMeteredSubscriptionItem: boolean + ): Promise { + if (!this.isBillingEnabled()) { + throw new BadDataException( + 'Billing is not enabled for this server.' + ); + } + + const subscription: Stripe.Response = + await this.stripe.subscriptions.retrieve(subscriptionId); + + if (!subscription) { + throw new BadDataException('Subscription not found'); + } + + if (subscription.status === 'canceled') { + // subscription is canceled. + return; + } + + const subscriptionItemOptions: Stripe.SubscriptionItemDeleteParams = + isMeteredSubscriptionItem + ? { + proration_behavior: 'create_prorations', + clear_usage: true, + } + : {}; + + await this.stripe.subscriptionItems.del( + subscriptionItemId, + subscriptionItemOptions + ); + } + + public async getSubscriptionItems( + subscriptionId: string + ): Promise> { + if (!this.isBillingEnabled()) { + throw new BadDataException( + 'Billing is not enabled for this server.' + ); + } + + const subscription: Stripe.Response = + await this.stripe.subscriptions.retrieve(subscriptionId); + + if (!subscription) { + throw new BadDataException('Subscription not found'); + } + + return subscription.items.data; + } + public async changePlan(data: { projectId: ObjectID; subscriptionId: string; diff --git a/Workers/DataMigrations/Index.ts b/Workers/DataMigrations/Index.ts index 395fff988f..4ca9aedbfa 100644 --- a/Workers/DataMigrations/Index.ts +++ b/Workers/DataMigrations/Index.ts @@ -2,6 +2,7 @@ import AddOwnerInfoToProjects from './AddOwnerInfoToProject'; import DataMigrationBase from './DataMigrationBase'; import MigrateDefaultUserNotificationRule from './MigrateDefaultUserNotificationRule'; import MigrateDefaultUserNotificationSetting from './MigrateDefaultUserSettingNotification'; +import MigrateToMeteredSubscription from './MigrateToMeteredSubscription'; // This is the order in which the migrations will be run. Add new migrations to the end of the array. @@ -9,6 +10,7 @@ const DataMigrations: Array = [ new MigrateDefaultUserNotificationRule(), new AddOwnerInfoToProjects(), new MigrateDefaultUserNotificationSetting(), + new MigrateToMeteredSubscription(), ]; export default DataMigrations; diff --git a/Workers/DataMigrations/MigrateToMeteredSubscription.ts b/Workers/DataMigrations/MigrateToMeteredSubscription.ts new file mode 100644 index 0000000000..9fcf8446aa --- /dev/null +++ b/Workers/DataMigrations/MigrateToMeteredSubscription.ts @@ -0,0 +1,99 @@ +import DataMigrationBase from './DataMigrationBase'; +import LIMIT_MAX from 'Common/Types/Database/LimitMax'; +import ProjectService from 'CommonServer/Services/ProjectService'; +import Project from 'Model/Models/Project'; +import BillingService, { + SubscriptionItem, +} from 'CommonServer/Services/BillingService'; +import { IsBillingEnabled } from 'CommonServer/Config'; +import AllMeteredPlans from 'CommonServer/Types/Billing/MeteredPlan/AllMeteredPlans'; +import QueryHelper from 'CommonServer/Types/Database/QueryHelper'; +import Sleep from 'Common/Types/Sleep'; + +export default class MigrateToMeteredSubscription extends DataMigrationBase { + public constructor() { + super('MigrateToMeteredSubscription'); + } + + public override async migrate(): Promise { + if (!IsBillingEnabled) { + return; + } + + const projects: Array = await ProjectService.findBy({ + query: { + paymentProviderMeteredSubscriptionId: QueryHelper.isNull(), + }, + select: { + _id: true, + paymentProviderSubscriptionId: true, + paymentProviderCustomerId: true, + trialEndsAt: true, + }, + limit: LIMIT_MAX, + skip: 0, + props: { + isRoot: true, + }, + }); + + for (const project of projects) { + if (!project.paymentProviderSubscriptionId) { + continue; + } + + if (!project.paymentProviderCustomerId) { + continue; + } + + // remove subscription item. + const subscriptionItems: Array = + await BillingService.getSubscriptionItems( + project.paymentProviderSubscriptionId + ); + + for (const subscriptionItem of subscriptionItems) { + if ( + subscriptionItem.plan.id === + 'price_1N6Cg9ANuQdJ93r7veN7YgsH' || + subscriptionItem.plan.id === + 'price_1N6B9EANuQdJ93r7fj3bhcWP' + ) { + await BillingService.removeSubscriptionItem( + project.paymentProviderSubscriptionId, + subscriptionItem.id, + true + ); + } + } + + // add metered subscription item and update metered quantity. + const meteredPlan: { + meteredSubscriptionId: string; + } = await BillingService.subscribeToMeteredPlan({ + projectId: project.id!, + customerId: project.paymentProviderCustomerId!, + serverMeteredPlans: AllMeteredPlans, + trialDate: project.trialEndsAt || null, + }); + + // update project with metered subscription id. + await ProjectService.updateOneById({ + id: project.id!, + data: { + paymentProviderMeteredSubscriptionId: + meteredPlan.meteredSubscriptionId, + }, + props: { + isRoot: true, + }, + }); + + await Sleep.sleep(500); + } + } + + public override async rollback(): Promise { + return; + } +}