Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/services/.server/pricingService.ts
T

653 lines
23 KiB
TypeScript

import { SubscriptionFeatureDto } from "~/application/dtos/subscriptions/SubscriptionFeatureDto";
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
import { SubscriptionPriceType } from "~/application/enums/subscriptions/SubscriptionPriceType";
import { SubscriptionProductDto } from "../../../application/dtos/subscriptions/SubscriptionProductDto";
import {
createSubscriptionProduct,
createSubscriptionPrice,
createSubscriptionFeature,
updateSubscriptionProductStripeId,
updateSubscriptionPriceStripeId,
deleteSubscriptionPrice,
deleteSubscriptionProduct,
updateSubscriptionProduct,
deleteSubscriptionFeatures,
deleteSubscriptionUsageBasedTier,
deleteSubscriptionUsageBasedPrice,
createSubscriptionUsageBasedPrice,
createSubscriptionUsageBasedTier,
getSubscriptionPriceByStripeId,
getSubscriptionUsageBasedPriceByStripeId,
getSubscriptionProduct,
getAllSubscriptionProducts,
} from "../../db/subscriptionProducts.db.server";
import {
createStripeProduct,
createStripePrice,
archiveStripePrice,
deleteStripeProduct,
updateStripeProduct,
createStripeUsageBasedPrice,
archiveStripeProduct,
getStripeSession,
createStripeCustomer,
createStripeSubscription,
} from "../../stripe.server";
import { CheckoutSessionStatus, SubscriptionPrice, SubscriptionUsageBasedPrice } from "@toolctrl/db/prisma-browser";
import Stripe from "stripe";
import { getOrPersistTenantSubscription, updateTenantSubscriptionCustomerId } from "../../db/tenantSubscriptions.db.server";
import { getTenant } from "../../db/tenants.db.server";
import { getCheckoutSessionStatus, updateCheckoutSessionStatus } from "../../db/stripe/checkoutSessions.db.server";
import { SubscriptionUsageBasedPriceDto } from "~/application/dtos/subscriptions/SubscriptionUsageBasedPriceDto";
import { TFunction } from "i18next";
import { TenantTypesApi } from "../../api/.server/TenantTypesApi";
import { addTenantSubscriptionProduct } from "../../db/subscriptions/tenantSubscriptionProducts.db.server";
import EventsService from "../../../modules/events/services/.server/EventsService";
import { SubscriptionSubscribedDto } from "~/modules/events/dtos/SubscriptionSubscribedDto";
import { SubscriptionPriceDto } from "~/application/dtos/subscriptions/SubscriptionPriceDto";
import { getUser } from "../../db/users.db.server";
import { cancelTenantSubscription, clearAllSubscriptionsCache, clearTenantSubscriptionCache, getActiveTenantSubscriptions } from "./subscriptionService";
import currencies from "~/application/pricing/currencies";
import { getAppConfiguration } from "~/utils/db/appConfiguration.db.server";
import { createScopedLogger } from "~/utils/logger.server";
const pricingLogger = createScopedLogger("pricing");
export async function createPlans(plans: SubscriptionProductDto[]) {
for (const plan of plans.sort((a, b) => a.order - b.order)) {
await createPlan(
plan,
plan.prices.map((price) => {
return {
billingPeriod: price.billingPeriod,
currency: price.currency,
price: Number(price.price),
};
}),
plan.features,
plan.usageBasedPrices
);
}
}
export async function createPlan(
plan: SubscriptionProductDto,
prices: { billingPeriod: SubscriptionBillingPeriod; price: number; currency: string; trialDays?: number }[],
features: SubscriptionFeatureDto[],
usageBasedPrices?: SubscriptionUsageBasedPriceDto[],
t?: TFunction
) {
// Create stripe product
const stripeProduct = await createStripeProduct({ title: plan.translatedTitle ?? plan.title });
// Save to db
const product = await createSubscriptionProduct({
stripeId: stripeProduct?.id ?? "",
order: plan.order,
title: plan.title,
model: plan.model,
description: plan.description ?? null,
badge: plan.badge ?? null,
groupTitle: plan.groupTitle ?? null,
groupDescription: plan.groupDescription ?? null,
active: plan.active,
public: plan.public,
billingAddressCollection: plan.billingAddressCollection ?? "auto",
hasQuantity: plan.hasQuantity ?? false,
canBuyAgain: plan.canBuyAgain ?? false,
});
if (!product) {
throw new Error("Could not create subscription product");
}
await Promise.all(
prices.map(async (price) => {
// Create stripe price
const stripePrice = await createStripePrice(stripeProduct?.id ?? "", price);
// Save to db
return await createSubscriptionPrice({
...price,
subscriptionProductId: product.id,
stripeId: stripePrice?.id ?? "",
type: SubscriptionPriceType.RECURRING,
billingPeriod: price.billingPeriod,
price: price.price,
currency: price.currency,
trialDays: price.trialDays ?? 0,
active: true,
});
})
);
await Promise.all(
features
.sort((a, b) => a.order - b.order)
.map(async (feature) => {
// Save to db
return await createSubscriptionFeature(product.id, feature);
})
);
if (usageBasedPrices) {
await Promise.all(
usageBasedPrices.map(async (usageBasedPrice) => {
pricingLogger.info(
{ op: "createUsageBasedPrice", productId: product.id, billingPeriod: usageBasedPrice.billingPeriod, unit: usageBasedPrice.unit },
"Creating usage-based price",
);
const stripePrice = await createStripeUsageBasedPrice(stripeProduct?.id ?? "", {
...usageBasedPrice,
unitTitle: t ? t(usageBasedPrice.unitTitle) : usageBasedPrice.unitTitle,
});
const createdPrice = await createSubscriptionUsageBasedPrice({
subscriptionProductId: product.id,
stripeId: stripePrice?.id ?? "",
billingPeriod: usageBasedPrice.billingPeriod,
currency: usageBasedPrice.currency,
unit: usageBasedPrice.unit,
unitTitle: usageBasedPrice.unitTitle,
unitTitlePlural: usageBasedPrice.unitTitlePlural,
usageType: usageBasedPrice.usageType,
aggregateUsage: usageBasedPrice.aggregateUsage,
tiersMode: usageBasedPrice.tiersMode,
billingScheme: usageBasedPrice.billingScheme,
});
await Promise.all(
usageBasedPrice.tiers.map(async (tierPrice) => {
await createSubscriptionUsageBasedTier({
subscriptionUsageBasedPriceId: createdPrice.id,
from: tierPrice.from,
to: tierPrice.to !== null && tierPrice.to !== undefined ? Number(tierPrice.to) : null,
perUnitPrice: tierPrice.perUnitPrice !== null && tierPrice.perUnitPrice !== undefined ? Number(tierPrice.perUnitPrice) : null,
flatFeePrice: tierPrice.flatFeePrice !== null && tierPrice.flatFeePrice !== undefined ? Number(tierPrice.flatFeePrice) : null,
});
})
);
})
);
}
}
export async function syncPlan(
plan: SubscriptionProductDto,
prices: { id?: string; billingPeriod: SubscriptionBillingPeriod; price: number; currency: string }[]
) {
if (!plan.id) {
throw new Error(`Plan ${plan.title} not found on database`);
}
const stripeProduct = await createStripeProduct({ title: plan.translatedTitle ?? plan.title });
if (!stripeProduct) {
throw new Error("Could not create product");
}
await updateSubscriptionProductStripeId(plan.id, {
stripeId: stripeProduct.id,
});
prices.map(async (price) => {
// Create stripe price
const stripePrice = await createStripePrice(stripeProduct?.id ?? "", price);
if (!stripePrice) {
throw new Error(`Could not create price ${plan.title} - ${price.price}`);
}
// Save to db
await updateSubscriptionPriceStripeId(price.id ?? "", {
stripeId: stripePrice?.id ?? "",
});
});
}
export async function updatePlan(plan: SubscriptionProductDto, features: SubscriptionFeatureDto[]) {
if (!plan.id) {
throw new Error(`Plan ${plan.title} not found on database`);
}
await updateStripeProduct(plan.stripeId, { title: plan.translatedTitle ?? plan.title });
await updateSubscriptionProduct(plan.id, {
order: plan.order,
title: plan.title,
model: plan.model,
description: plan.description ?? null,
badge: plan.badge ?? null,
groupTitle: plan.groupTitle ?? null,
groupDescription: plan.groupDescription ?? null,
public: plan.public,
billingAddressCollection: plan.billingAddressCollection ?? "auto",
hasQuantity: plan.hasQuantity === undefined ? undefined : plan.hasQuantity,
canBuyAgain: plan.canBuyAgain === undefined ? undefined : plan.canBuyAgain,
});
await deleteSubscriptionFeatures(plan.id ?? "");
await clearAllSubscriptionsCache();
return await Promise.all(
features
.sort((a, b) => a.order - b.order)
.map(async (feature) => {
return await createSubscriptionFeature(plan.id ?? "", feature);
})
);
}
export async function deletePlan(plan: SubscriptionProductDto) {
await clearAllSubscriptionsCache();
pricingLogger.info({ op: "deletePlan.flatPrices", planId: plan.id, count: plan.prices?.length ?? 0 }, "Deleting flat-rate prices");
await Promise.all(
plan.prices
.filter((f) => f.stripeId)
.map(async (price) => {
await archiveStripePrice(price.stripeId);
if (price.id) {
await deleteSubscriptionPrice(price.id);
}
return null;
})
);
pricingLogger.info(
{ op: "deletePlan.usageBasedPrices", planId: plan.id, count: plan.usageBasedPrices?.length ?? 0 },
"Deleting usage-based prices",
);
if (plan.usageBasedPrices) {
await Promise.all(
plan.usageBasedPrices?.map(async (price) => {
await archiveStripePrice(price.stripeId);
await Promise.all(
price.tiers.map(async (tier) => {
return deleteSubscriptionUsageBasedTier(tier.id);
})
);
await deleteSubscriptionUsageBasedPrice(price.id);
return null;
})
);
}
pricingLogger.info({ op: "deletePlan.product", planId: plan.id, stripeId: plan.stripeId }, "Deleting Stripe product");
if (plan.stripeId) {
try {
await deleteStripeProduct(plan.stripeId);
} catch (e: any) {
pricingLogger.warn(
{ err: e, op: "deletePlan.product.archive", planId: plan.id, stripeId: plan.stripeId },
"Could not delete Stripe product; archiving instead",
);
await archiveStripeProduct(plan.stripeId);
}
}
if (plan.id) {
await deleteSubscriptionProduct(plan.id);
}
}
export type CheckoutSessionResponse = {
id: string;
customer: {
id: string;
email: string;
name: string;
};
products: {
id: string;
title: string;
quantity: number;
subscription: string | undefined;
prices: { flatPrice?: SubscriptionPrice; usageBasedPrice?: SubscriptionUsageBasedPrice; quantity?: number }[];
}[];
status: CheckoutSessionStatus | null;
};
export async function getAcquiredItemsFromCheckoutSession(session_id: string | null): Promise<CheckoutSessionResponse | null> {
const session = await getStripeSession(session_id ?? "");
if (!session || session.status !== "complete") {
return null;
}
const prices: { flatPrice?: SubscriptionPrice; usageBasedPrice?: SubscriptionUsageBasedPrice; quantity?: number }[] = [];
try {
let line_items: { price: Stripe.Price; quantity: number | undefined }[] = [];
if (session.line_items) {
session.line_items.data.forEach((item) => {
if (item.price) {
line_items.push({
price: item.price,
quantity: item.quantity ?? undefined,
});
}
});
}
await Promise.all(
line_items.map(async (line_item) => {
const flatPrice = await getSubscriptionPriceByStripeId(line_item.price.id);
const usageBasedPrice = await getSubscriptionUsageBasedPriceByStripeId(line_item.price.id);
const quantity = line_item.quantity ?? undefined;
if (!flatPrice && !usageBasedPrice) {
throw new Error("Price not found: " + line_item.price.id);
}
prices.push({
flatPrice: flatPrice ?? undefined,
usageBasedPrice: usageBasedPrice ?? undefined,
quantity,
});
})
);
} catch (e: any) {
pricingLogger.error({ err: e, op: "getAcquiredItemsFromCheckoutSession" }, "Failed to load acquired items from checkout session");
}
const products: {
id: string;
title: string;
quantity: number;
subscription: string | undefined;
prices: { flatPrice?: SubscriptionPrice; usageBasedPrice?: SubscriptionUsageBasedPrice; quantity?: number }[];
}[] = [];
prices.forEach((item) => {
const productId = item.flatPrice?.subscriptionProductId ?? item.usageBasedPrice?.subscriptionProductId ?? "";
if (!productId) {
return;
}
const product = products.find((p) => p.id === productId);
if (!product) {
products.push({
id: productId,
title: "",
quantity: item.quantity ?? 1,
prices: [item],
subscription: session.subscription?.toString(),
});
} else {
product?.prices.push(item);
}
});
await Promise.all(
products.map(async (product) => {
const subscriptionProduct = await getSubscriptionProduct(product.id);
product.title = subscriptionProduct?.title ?? "";
})
);
const status = await getCheckoutSessionStatus(session.id);
return {
id: session.id,
customer: {
id: session.customer?.toString() ?? "",
name: session.customer_details?.name ?? "",
email: session.customer_details?.email ?? "",
},
products,
status,
};
}
export async function addTenantProductsFromCheckoutSession({
request,
tenantId,
user,
checkoutSession,
createdUserId,
createdTenantId,
t,
}: {
request: Request;
tenantId: string;
user: { id: string; email: string };
checkoutSession: CheckoutSessionResponse;
createdUserId?: string | null;
createdTenantId?: string | null;
t: TFunction;
}) {
clearTenantSubscriptionCache(tenantId);
const appConfiguration = await getAppConfiguration({ request });
const tenant = await getTenant(tenantId);
if (!tenant) {
throw new Error("Tenant not found");
}
const tenantSubscription = await getOrPersistTenantSubscription(tenant.id);
if (!tenantSubscription.stripeCustomerId) {
const customer = await createStripeCustomer(user.email, tenant.name);
if (customer) {
tenantSubscription.stripeCustomerId = customer.id;
await updateTenantSubscriptionCustomerId(tenant.id, {
stripeCustomerId: customer.id,
});
}
}
const existingSessionStatus = await getCheckoutSessionStatus(checkoutSession.id);
if (!checkoutSession) {
throw new Error(t("settings.subscription.checkout.invalid"));
} else if (checkoutSession.customer.id !== tenantSubscription.stripeCustomerId) {
throw new Error(t("settings.subscription.checkout.invalidCustomer"));
} else if (!existingSessionStatus) {
throw new Error(t("settings.subscription.checkout.invalid"));
} else if (!existingSessionStatus.pending) {
throw new Error(t("settings.subscription.checkout.alreadyProcessed"));
} else {
await updateCheckoutSessionStatus(checkoutSession.id, {
pending: false,
createdUserId,
createdTenantId,
});
if (!appConfiguration.subscription.multiple) {
const hasSubscribed = checkoutSession.products.some((product) => product.subscription);
const activeSubscriptions = await getActiveTenantSubscriptions(tenant.id);
const recurringSubscriptions = activeSubscriptions?.products.filter((f) => f.stripeSubscriptionId) || [];
pricingLogger.info(
{
op: "cancelExistingSubscriptions",
tenantId: tenant.id,
hasSubscribed,
recurringCount: recurringSubscriptions.length,
},
"Cancelling existing subscriptions before new subscribe",
);
if (hasSubscribed && recurringSubscriptions.length > 0) {
await Promise.all(
recurringSubscriptions.map(async (recurringSubscription) => {
pricingLogger.info(
{
op: "cancelSubscription",
tenantId: tenant.id,
subscriptionId: recurringSubscription.id,
productId: recurringSubscription.subscriptionProductId,
},
"Cancelling tenant subscription",
);
return await cancelTenantSubscription(recurringSubscription.id, { tenantId, userId: user.id, t });
})
);
}
}
await Promise.all(
checkoutSession.products.map(async (product) => {
// const existingProduct = await getTenantSubscriptionProduct(tenant.id, product.id);
// if (!existingProduct) {
await addTenantSubscriptionProduct({
tenantSubscriptionId: tenantSubscription.id,
subscriptionProductId: product.id ?? "",
quantity: product.quantity,
stripeSubscriptionId: product.subscription ?? "",
fromCheckoutSessionId: checkoutSession.id,
prices: product.prices.map((price) => {
return {
subscriptionPriceId: price.flatPrice?.id,
subscriptionUsageBasedPriceId: price.usageBasedPrice?.id,
};
}),
});
// } else {
// // is active
// if (!existingProduct.endsAt) {
// await updateTenantSubscriptionProduct(existingProduct.id, {
// quantity: (existingProduct.quantity ?? 1) + 1,
// });
// } else {
// await updateTenantSubscriptionProduct(existingProduct.id, {
// quantity: product.quantity,
// endsAt: null,
// cancelledAt: null,
// });
// }
// }
const subscriptionProduct = await getSubscriptionProduct(product.id);
if (subscriptionProduct) {
function checkUpgradeDowngrade(plan: SubscriptionProductDto) {
const existing = tenantSubscription?.products.find((f) => f)?.subscriptionProduct;
if (existing) {
if (plan.order > existing.order) {
return { upgrade: true };
} else {
return { downgrade: true };
}
}
}
let event: "subscription.subscribed" | "subscription.upgraded" | "subscription.downgraded" = "subscription.subscribed";
if (tenantSubscription.products.length > 0) {
const upgradeDowngrade = checkUpgradeDowngrade(subscriptionProduct);
if (upgradeDowngrade?.upgrade) {
event = "subscription.upgraded";
} else if (upgradeDowngrade?.downgrade) {
event = "subscription.downgraded";
}
}
await EventsService.create({
request,
event,
tenantId: tenant.id,
userId: user.id,
data: {
user: { id: user.id, email: user.email },
tenant: { id: tenant.id, name: tenant.name, slug: tenant.slug },
subscription: {
product: { id: product.id, title: t(subscriptionProduct.title) || subscriptionProduct.title },
session: checkoutSession.id,
},
} satisfies SubscriptionSubscribedDto,
});
await TenantTypesApi.setTenantTypes({ tenantId, subscriptionProduct });
}
})
);
}
}
export async function autosubscribeToTrialOrFreePlan({
request,
t,
tenantId,
userId,
}: {
request: Request;
t: TFunction;
tenantId: string;
userId: string | null;
}) {
pricingLogger.info({ op: "autosubscribeToTrialOrFreePlan.start", tenantId }, "Starting auto-subscribe to trial/free plan");
const tenant = await getTenant(tenantId);
if (!tenant) {
pricingLogger.warn({ op: "autosubscribeToTrialOrFreePlan.start", tenantId }, "Tenant not found; skipping");
return;
}
if (!tenant.subscription?.stripeCustomerId) {
pricingLogger.warn({ op: "autosubscribeToTrialOrFreePlan.start", tenantId }, "Tenant has no Stripe customer id; skipping");
return;
}
const defaultCurrency = currencies.find((f) => f.default)?.value;
if (!defaultCurrency) {
return;
}
const allProducts = await getAllSubscriptionProducts(true);
const productsFree: SubscriptionPriceDto[] = [];
const productsWithTrialDays: SubscriptionPriceDto[] = [];
pricingLogger.debug(
{ op: "autosubscribeToTrialOrFreePlan.products", tenantId, count: allProducts.length },
"Auto-subscribe candidate products loaded",
);
allProducts.forEach((product) => {
if (product.prices.length === 0) {
return;
}
const allPricesAreZero = product.prices.every((price) => price.currency === defaultCurrency && (price.price === undefined || Number(price.price) === 0));
if (allPricesAreZero) {
productsFree.push(product.prices[0]);
}
const firstPriceWithTrialDays = product.prices.find(
(price) => price.currency === defaultCurrency && (price.trialDays > 0 || price.price === undefined || Number(price.price) === 0)
);
if (firstPriceWithTrialDays) {
productsWithTrialDays.push(firstPriceWithTrialDays);
}
});
try {
if (productsWithTrialDays.length > 0) {
pricingLogger.info(
{
op: "autosubscribeToTrialOrFreePlan",
tenantId: tenant.id,
productCount: productsWithTrialDays.length,
productIds: productsWithTrialDays.map((p) => p.subscriptionProductId),
},
"Autosubscribing tenant to trial/free plan",
);
const price = productsWithTrialDays[0];
const trial_end = Math.floor(Date.now() / 1000) + (price.trialDays || 30) * 24 * 60 * 60;
const stripeSubscription = await createStripeSubscription(tenant.subscription.stripeCustomerId, price.stripeId, trial_end);
await addTenantSubscriptionProduct({
tenantSubscriptionId: tenant.subscription.id,
subscriptionProductId: price.subscriptionProductId,
quantity: 1,
stripeSubscriptionId: stripeSubscription.id,
fromCheckoutSessionId: null,
prices: [
{
subscriptionPriceId: price.id,
},
],
});
const user = userId ? await getUser(userId) : null;
await EventsService.create({
request,
event: "subscription.subscribed",
tenantId: tenant.id,
userId: user?.id ?? null,
data: {
user: !user ? null : { id: user.id, email: user.email },
tenant: { id: tenant.id, name: tenant.name, slug: tenant.slug },
subscription: {
product: {
id: price.subscriptionProductId,
title: t(price.subscriptionProduct?.title ?? "") || price.subscriptionProduct?.title || "",
},
},
} satisfies SubscriptionSubscribedDto,
});
} else if (productsFree.length > 0) {
// TODO: IMPLEMENT
pricingLogger.info(
{ op: "autosubscribeToTrialOrFreePlan.free", tenantId, count: productsFree.length },
"Free products available but auto-subscribe-free path not implemented yet",
);
} else {
pricingLogger.info(
{ op: "autosubscribeToTrialOrFreePlan.none", tenantId },
"No auto-subscription products/plans found",
);
}
} catch (e: any) {
pricingLogger.error({ err: e, op: "autosubscribeToTrialOrFreePlan", tenantId }, "Auto-subscribe failed");
}
}