653 lines
23 KiB
TypeScript
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");
|
|
}
|
|
}
|