This commit is contained in:
Simon Larsen
2023-08-04 13:24:19 +01:00
parent 069063d50f
commit 73953901db
3 changed files with 226 additions and 35 deletions

View File

@@ -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<typeof ServerMeteredPlan>;
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<Stripe.Subscription> =
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<Stripe.Subscription> =
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<void> {
if (!this.isBillingEnabled()) {
throw new BadDataException(
'Billing is not enabled for this server.'
);
}
const subscription: Stripe.Response<Stripe.Subscription> =
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<Array<SubscriptionItem>> {
if (!this.isBillingEnabled()) {
throw new BadDataException(
'Billing is not enabled for this server.'
);
}
const subscription: Stripe.Response<Stripe.Subscription> =
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;

View File

@@ -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<DataMigrationBase> = [
new MigrateDefaultUserNotificationRule(),
new AddOwnerInfoToProjects(),
new MigrateDefaultUserNotificationSetting(),
new MigrateToMeteredSubscription(),
];
export default DataMigrations;

View File

@@ -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<void> {
if (!IsBillingEnabled) {
return;
}
const projects: Array<Project> = 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<SubscriptionItem> =
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<void> {
return;
}
}