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

619 lines
23 KiB
TypeScript

import { Prisma, SubscriptionPrice } from "@toolctrl/db/prisma-browser";
import { DefaultFeatures } from "~/application/dtos/shared/DefaultFeatures";
import { PlanFeatureUsageDto } from "~/application/dtos/subscriptions/PlanFeatureUsageDto";
import { SubscriptionFeatureDto } from "~/application/dtos/subscriptions/SubscriptionFeatureDto";
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
import { SubscriptionFeatureLimitType } from "~/application/enums/subscriptions/SubscriptionFeatureLimitType";
import currencies from "~/application/pricing/currencies";
import { db } from "../../db.server";
import { findSimpleEntityByName } from "../../db/entities/entities.db.server";
import { createCheckoutSessionStatus, getCheckoutSessionStatus } from "../../db/stripe/checkoutSessions.db.server";
import { getAllSubscriptionFeatures } from "../../db/subscriptionProducts.db.server";
import {
createTenantSubscriptionUsageRecord,
listTenantSubscriptionIds,
updateTenantSubscriptionProduct,
type TenantSubscriptionWithDetails,
} from "@toolctrl/db/subscriptions.server";
import { getTenantSubscription, getTenantSubscriptions } from "../../db/tenantSubscriptions.db.server";
import { sendEmail } from "~/modules/emails/services/EmailService";
import { apiKeyCreditableStatusCodes, apiKeyIgnoreEndpoints } from "../../helpers/ApiKeyHelper";
import { cancelStripeSubscription, getStripeSession, getStripeSubscription } from "../../stripe.server";
import { getBaseURL } from "../../url.server";
import { getAcquiredItemsFromCheckoutSession } from "./pricingService";
import { cachified, clearCacheKey } from "../../cache.server";
import { getSubscriptionCache } from "./subscriptionCache.server";
import DateUtils from "~/utils/shared/DateUtils";
import { TFunction } from "i18next";
import { cancelTenantSubscriptionProduct, getTenantSubscriptionProductById } from "~/utils/db/subscriptions/tenantSubscriptionProducts.db.server";
import { SubscriptionCancelledDto } from "~/modules/events/dtos/SubscriptionCancelledDto";
import EventsService from "~/modules/events/services/.server/EventsService";
import { getTenant } from "~/utils/db/tenants.db.server";
import { getUser } from "~/utils/db/users.db.server";
import EmailTemplates from "~/modules/emails/utils/EmailTemplates";
import { getMailTranslatorFromRequest } from "~/locale/mail-translator.server";
import { getAppConfiguration } from "~/utils/db/appConfiguration.db.server";
export async function getPlanFeaturesUsage(tenantId: string, tenantSubscription?: TenantSubscriptionWithDetails | null): Promise<PlanFeatureUsageDto[]> {
let subscription: TenantSubscriptionWithDetails | null = null;
if (tenantSubscription === undefined) {
subscription = await getActiveTenantSubscriptions(tenantId);
} else {
subscription = tenantSubscription;
}
const myUsage: PlanFeatureUsageDto[] = [];
let allFeatures: SubscriptionFeatureDto[] = [];
const features: { name: string; order: number; title: string; type: SubscriptionFeatureLimitType; value: number; accumulate: boolean }[] =
await getAllSubscriptionFeatures();
features
.filter((f) => f.name)
.forEach((feature) => {
const existing = allFeatures.find((f) => f.name === feature.name);
if (!existing) {
allFeatures.push({
order: feature.order,
name: feature.name,
title: feature.title,
type: feature.type,
value: feature.value,
accumulate: feature.accumulate,
});
}
});
allFeatures = allFeatures.sort((a, b) => a.order - b.order);
await Promise.all(
allFeatures.map(async (item) => {
let myFeatures: SubscriptionFeatureDto[] = [];
subscription?.products.forEach((product) => {
const feature = product.subscriptionProduct.features.find((f) => f.name === item.name);
if (feature) {
if (!product.quantity) {
myFeatures.push(feature);
} else {
// per-seat or one-time multiplication
for (let idx = 0; idx < product.quantity; idx++) {
myFeatures.push(feature);
}
}
}
});
const existingSubscriptionFeature = mergeFeatures(myFeatures);
const feature = existingSubscriptionFeature ?? item;
const entity = await findSimpleEntityByName(item.name);
const usage: PlanFeatureUsageDto = {
entity: entity ?? undefined,
order: feature.order,
title: feature.title,
name: feature.name,
type: feature.type,
value: feature.value,
used: 0,
remaining: 0,
enabled: feature.type !== SubscriptionFeatureLimitType.NOT_INCLUDED,
message: "",
};
if (!existingSubscriptionFeature) {
usage.type = SubscriptionFeatureLimitType.NOT_INCLUDED;
usage.enabled = false;
if (subscription?.products && subscription.products.length > 0) {
usage.message = "featureLimits.upgradeSubscription";
} else {
usage.message = "featureLimits.noSubscription";
}
} else {
if (feature.type === SubscriptionFeatureLimitType.NOT_INCLUDED) {
usage.enabled = false;
usage.message = "featureLimits.notIncluded";
} else if (feature.type === SubscriptionFeatureLimitType.MAX) {
usage.used = await getUsed(tenantId, feature, subscription);
usage.remaining = usage.value - usage.used;
if (usage.remaining <= 0) {
usage.message = `You've reached the limit (${usage.used}/${usage.value}) for ${entity?.titlePlural ?? feature.name}`;
usage.enabled = false;
}
} else if (feature.type === SubscriptionFeatureLimitType.MONTHLY) {
usage.used = await getUsed(tenantId, feature, subscription);
usage.remaining = usage.value - usage.used;
usage.period = getUsedPeriod(subscription);
if (usage.remaining <= 0) {
// var now = new Date();
// const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
usage.message = `You've reached the limit this month (${usage.used}/${usage.value}) for ${entity?.titlePlural ?? feature.name}`;
usage.enabled = false;
}
} else if (feature.type === SubscriptionFeatureLimitType.UNLIMITED) {
usage.remaining = "unlimited";
}
}
myUsage.push(usage);
})
);
return myUsage.sort((a, b) => a.order - b.order);
}
function mergeFeatures(features: SubscriptionFeatureDto[]) {
if (features.length === 0) {
return undefined;
}
const mergedFeature: SubscriptionFeatureDto = {
order: features[0].order,
title: features[0].title,
name: features[0].name,
type: SubscriptionFeatureLimitType.NOT_INCLUDED,
value: 0,
accumulate: features[0].accumulate,
};
let firstFeature = true;
features.forEach((feature) => {
if (mergedFeature.type < feature.type) {
mergedFeature.type = feature.type;
}
// console.log({ mergedFeature });
if (mergedFeature.accumulate || firstFeature) {
mergedFeature.value += feature.value;
}
firstFeature = false;
});
return mergedFeature;
}
export async function getPlanFeatureUsage(
tenantId: string,
featureName: string,
tenantSubscription?: TenantSubscriptionWithDetails | null
): Promise<PlanFeatureUsageDto | undefined> {
const usage = await getPlanFeaturesUsage(tenantId, tenantSubscription);
return usage.find((f) => f.name === featureName);
}
function getUsedPeriod(subscription: TenantSubscriptionWithDetails | null) {
const date = new Date(),
y = date.getFullYear(),
m = date.getMonth();
let firstDay = new Date(y, m, 1, 0, 0, 1);
let lastDay = new Date(y, m + 1, 0, 23, 59, 59);
// if it's december, last day is january 0
if (m === 11) {
lastDay = new Date(y + 1, 0, 0, 23, 59, 59);
}
subscription?.products.forEach((product) => {
if (product.currentPeriodStart && product.currentPeriodEnd) {
firstDay = product.currentPeriodStart;
lastDay = product.currentPeriodEnd;
}
});
return {
firstDay,
lastDay,
};
}
async function getUsed(tenantId: string, feature: SubscriptionFeatureDto, subscription: TenantSubscriptionWithDetails | null): Promise<number> {
const { firstDay, lastDay } = await getUsedPeriod(subscription);
if (feature.name === DefaultFeatures.Users) {
if (feature.type === SubscriptionFeatureLimitType.MONTHLY) {
return await db.tenantUser.count({
where: {
tenantId,
createdAt: {
gte: firstDay,
lt: lastDay,
},
},
});
} else if (feature.type === SubscriptionFeatureLimitType.MAX) {
return await db.tenantUser.count({
where: {
tenantId,
},
});
}
} else if (feature.name === DefaultFeatures.API) {
if (feature.type === SubscriptionFeatureLimitType.MONTHLY) {
return await db.apiKeyLog.count({
where: {
tenantId,
status: { in: apiKeyCreditableStatusCodes },
endpoint: { notIn: apiKeyIgnoreEndpoints },
createdAt: {
gte: firstDay,
lt: lastDay,
},
},
});
} else if (feature.type === SubscriptionFeatureLimitType.MAX) {
return await db.apiKeyLog.count({
where: {
tenantId,
status: { in: apiKeyCreditableStatusCodes },
endpoint: { notIn: apiKeyIgnoreEndpoints },
},
});
}
} else if (feature.name === DefaultFeatures.Credits) {
if (feature.type === SubscriptionFeatureLimitType.MONTHLY) {
return await getSpentCredits({
tenantId,
createdAt: {
gte: firstDay,
lt: lastDay,
},
});
} else if (feature.type === SubscriptionFeatureLimitType.MAX) {
return await getSpentCredits({ tenantId });
}
} else {
const entity = await findSimpleEntityByName(feature.name);
// if (!entity) {
// // throw new Error("Entity does not exist with plural title: " + feature.name);
// }
if (feature.type === SubscriptionFeatureLimitType.MONTHLY) {
const where: Prisma.RowWhereInput = {
tenantId,
entityId: entity?.id ?? "",
createdAt: {
gte: firstDay,
lt: lastDay,
},
};
return db.row.count({
where,
});
} else if (feature.type === SubscriptionFeatureLimitType.MAX) {
const count = await db.row.count({
where: {
tenantId,
entityId: entity?.id ?? "",
},
});
return count;
}
}
return 0;
}
async function getSpentCredits(where: Prisma.CreditWhereInput): Promise<number> {
const spent = await db.credit.aggregate({
where: {
...where,
amount: { lt: 0 },
},
_sum: {
amount: true,
},
});
return Math.abs(spent._sum.amount ?? 0);
}
// export async function getPlanFeatureUsage(tenantId: string, featureName: string): Promise<PlanFeatureUsageDto> {
// const myUsage: PlanFeatureUsageDto[] = [];
// let allFeatures: SubscriptionFeatureDto[] = [];
// const features = await getAllSubscriptionFeatures();
// features.forEach((feature) => {
// const existing = allFeatures.find((f) => f.name === feature.name);
// if (!existing) {
// allFeatures.push({
// order: feature.order,
// name: feature.name,
// title: feature.title,
// type: feature.type,
// value: feature.value,
// });
// }
// });
// allFeatures = allFeatures.sort((a, b) => a.order - b.order);
// allFeatures.forEach((feature) => {
// const featureUsage: PlanFeatureUsageDto = {
// order: feature.order,
// title: feature.title,
// name: feature.name,
// type: feature.type,
// value: feature.value,
// used: 0,
// remaining: 0,
// };
// if (feature.type === SubscriptionFeatureLimitType.MAX) {
// featureUsage.used = 2;
// featureUsage.remaining = featureUsage.value - featureUsage.used;
// } else if (feature.type === SubscriptionFeatureLimitType.MONTHLY) {
// featureUsage.used = 3;
// featureUsage.remaining = featureUsage.value - featureUsage.used;
// }
// myUsage.push(featureUsage);
// });
// return myUsage;
// }
export async function getActiveTenantSubscriptions(tenantId: string) {
const mySubscription = await cachified({
key: `tenantSubscription:${tenantId}`,
ttl: 1000 * 60 * 60, // 1 hour
getFreshValue: () => getTenantSubscription(tenantId),
});
if (mySubscription) {
await Promise.all(
mySubscription.products.map(async (item) => {
if (item.stripeSubscriptionId) {
let currentPeriod =
item.currentPeriodStart && item.currentPeriodEnd
? {
start: DateUtils.getDateStartOfDay(item.currentPeriodStart),
end: DateUtils.getDateEndOfDay(item.currentPeriodEnd),
}
: undefined;
const today = new Date();
let todayIsInCurrentPeriod = false;
if (currentPeriod) {
todayIsInCurrentPeriod = today >= item.currentPeriodStart! && today <= item.currentPeriodEnd!;
}
if (currentPeriod && todayIsInCurrentPeriod) {
item.currentPeriodStart = currentPeriod.start;
item.currentPeriodEnd = currentPeriod.end;
} else {
const stripeSubscription = await getStripeSubscription(item.stripeSubscriptionId);
if (stripeSubscription) {
let startOfDay = new Date((stripeSubscription.items.data[0]?.current_period_start ?? 0) * 1000);
let endOfDay = new Date((stripeSubscription.items.data[0]?.current_period_end ?? 0) * 1000);
item.currentPeriodStart = DateUtils.getDateStartOfDay(startOfDay);
item.currentPeriodEnd = DateUtils.getDateEndOfDay(endOfDay);
}
const updatedItem = await updateTenantSubscriptionProduct({ id: item.id, tenantId: mySubscription.tenantId }, {
currentPeriodStart: item.currentPeriodStart,
currentPeriodEnd: item.currentPeriodEnd,
});
// Cross-replica purge: the Stripe billing-period sync mutates the
// subscription, so peer web replicas must drop their L1 too —
// a bare clearCacheKey would only purge this process.
clearTenantSubscriptionCache(updatedItem.tenantSubscription.tenantId);
}
}
})
);
mySubscription.products = mySubscription.products.filter((f) => !f.endsAt || new Date(f.endsAt) > new Date());
mySubscription.products = mySubscription.products.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
return mySubscription;
}
export async function getActiveTenantsSubscriptions() {
const subscriptions = await getTenantSubscriptions();
return await Promise.all(
subscriptions.map(async (mySubscription) => {
await Promise.all(
mySubscription.products.map(async (item) => {
if (item.stripeSubscriptionId) {
const stripeSubscription = await getStripeSubscription(item.stripeSubscriptionId);
if (stripeSubscription) {
item.currentPeriodStart = new Date((stripeSubscription.items.data[0]?.current_period_start ?? 0) * 1000);
item.currentPeriodEnd = new Date((stripeSubscription.items.data[0]?.current_period_end ?? 0) * 1000);
}
}
})
);
mySubscription.products = mySubscription.products.filter((f) => !f.endsAt || new Date(f.endsAt) > new Date());
return mySubscription;
})
);
}
export async function reportUsage(tenantId: string, unit: string) {
const tenantSubscription = await getActiveTenantSubscriptions(tenantId);
if (!tenantSubscription) {
return;
}
await Promise.all(
tenantSubscription.products.map(async (product) => {
return await Promise.all(
product.prices.map(async (price) => {
if (!product.stripeSubscriptionId) {
return;
}
if (price.subscriptionUsageBasedPrice?.unit === unit) {
const stripeSubscription = await getStripeSubscription(product.stripeSubscriptionId);
const subscriptionItem = stripeSubscription?.items.data.find((f) => f.price.id === price.subscriptionUsageBasedPrice?.stripeId);
if (subscriptionItem) {
// Stripe submission is handled by the meter-based reporter in
// `apps/worker/src/jobs/ai-cost-stripe-report.ts`; this path only
// keeps local bookkeeping for tenant-facing usage history.
await createTenantSubscriptionUsageRecord({
tenantSubscriptionProductPriceId: price.id,
timestamp: Math.floor(Date.now() / 1000),
quantity: 1,
stripeSubscriptionItemId: subscriptionItem.id,
});
}
}
})
);
})
);
}
export async function persistCheckoutSessionStatus({
request,
id,
fromUrl,
fromUserId,
fromTenantId,
}: {
request: Request;
id: string;
fromUrl: string;
fromUserId?: string | null;
fromTenantId?: string | null;
}) {
const existingCheckoutSession = await getCheckoutSessionStatus(id);
if (!existingCheckoutSession) {
const stripeCheckoutSession = await getStripeSession(id);
if (stripeCheckoutSession) {
const session = await createCheckoutSessionStatus({
id: stripeCheckoutSession.id,
email: stripeCheckoutSession.customer_details?.email ?? "",
fromUrl,
fromUserId,
fromTenantId,
});
if (!session.fromUserId && !session.fromTenantId) {
const sessionResponse = await getAcquiredItemsFromCheckoutSession(session.id);
if (sessionResponse && sessionResponse.products.length > 0) {
const mailTranslator = await getMailTranslatorFromRequest(request);
await sendEmail({
request,
to: session.email,
alias: "account-setup",
...EmailTemplates.ACCOUNT_SETUP_EMAIL.parse({
appConfiguration: await getAppConfiguration({ request }),
data: {
email: session.email,
plan: sessionResponse.products[0].title,
action_url: `${getBaseURL(request)}/pricing/${session.id}/success`,
},
t: mailTranslator.t,
locale: mailTranslator.locale,
}),
});
}
}
}
}
}
export async function getMrr(currency: string) {
const activeSubscriptions = await getActiveTenantsSubscriptions();
let summary: { total: number; count: number } = {
total: 0,
count: 0,
};
activeSubscriptions.forEach((s) => {
s.products.forEach((p) => {
summary.count++;
p.prices.forEach((f) => {
summary.total += getPriceInCurrency(f.subscriptionPrice, currency);
});
});
});
return summary;
}
function getPriceInCurrency(subscriptionPrice: SubscriptionPrice | null, currency: string) {
if (!subscriptionPrice) {
return 0;
}
let total = 0;
if (subscriptionPrice.billingPeriod === SubscriptionBillingPeriod.MONTHLY) {
total = Number(subscriptionPrice.price);
} else if (subscriptionPrice.billingPeriod === SubscriptionBillingPeriod.YEARLY) {
total = Number(subscriptionPrice.price) / 12;
} else if (subscriptionPrice.billingPeriod === SubscriptionBillingPeriod.WEEKLY) {
total = Number(subscriptionPrice.price) * 4;
} else if (subscriptionPrice.billingPeriod === SubscriptionBillingPeriod.DAILY) {
total = Number(subscriptionPrice.price) * 30;
}
if (currency !== subscriptionPrice.currency) {
total = convertToCurrency({ from: subscriptionPrice.currency, to: currency, price: total });
}
return total;
}
export function convertToCurrency({ from, price, to }: { from: string; price: number; to: string }): number {
const fromCurrency = currencies.find((f) => f.value === from);
const toCurrency = currencies.find((f) => f.value === to);
if (!fromCurrency || !toCurrency) {
return 0;
}
const fromParity = fromCurrency.parities?.find((f) => f.from === to);
const toParity = toCurrency.parities?.find((f) => f.from === from);
if (fromParity && fromParity.parity !== 0) {
return price / fromParity.parity;
} else if (toParity && toParity.parity !== 0) {
return price / toParity.parity;
}
return 0;
}
/**
* Invalidate the cached subscription for a single tenant. Prefer this over
* `clearAllSubscriptionsCache` whenever the change is tenant-scoped (e.g.
* Stripe webhooks) so we don't thrash every other tenant's cache entry.
*
* Publishes a cross-replica invalidation on the `cache:subscription:invalidate`
* channel via the two-tier cache so peer web replicas drop their L1 too.
* The publish is fire-and-forget — failures are logged but never thrown.
*/
export function clearTenantSubscriptionCache(tenantId: string) {
clearCacheKey(`tenantSubscription:${tenantId}`);
// Best-effort fan-out to peer replicas; never blocks the caller.
void getSubscriptionCache()
.invalidate(tenantId)
.catch(() => {
/* errors are logged inside the helper via its logger */
});
}
/**
* Invalidate every cached tenant subscription. Use only for global changes
* (plan/feature CRUD, bulk migrations). Also broadcasts a wildcard
* invalidation on the subscription pub/sub channel so peer replicas flush
* their L1 in one shot rather than per-tenant.
*/
export async function clearAllSubscriptionsCache() {
const tenantSubs = await listTenantSubscriptionIds();
tenantSubs.forEach((tenantSub) => {
clearCacheKey(`tenantSubscription:${tenantSub.tenantId}`);
});
// Wildcard fan-out — peer replicas flush their L1 entirely.
await getSubscriptionCache().invalidate().catch(() => {
/* logged inside helper */
});
}
export async function cancelTenantSubscription(
tenantSubscriptionProductId: string,
{ tenantId, userId, t }: { tenantId: string; userId: string; t?: TFunction }
) {
const tenantSubscriptionProduct = await getTenantSubscriptionProductById({ id: tenantSubscriptionProductId, tenantId });
if (!tenantSubscriptionProduct) {
throw new Error("Subscription product not found");
}
if (!tenantSubscriptionProduct.stripeSubscriptionId) {
return;
}
const user = userId ? await getUser(userId) : null;
const tenant = await getTenant(tenantId);
if (user && tenant) {
await EventsService.create({
event: "subscription.cancelled",
tenantId: tenant.id,
userId: user.id,
data: {
user: { id: user.id, email: user.email },
tenant: { id: tenant.id, name: tenant.name },
subscription: {
product: {
id: tenantSubscriptionProduct.subscriptionProductId,
title: t ? t(tenantSubscriptionProduct.subscriptionProduct.title) : tenantSubscriptionProduct.subscriptionProduct.title,
},
},
} satisfies SubscriptionCancelledDto,
});
}
await cancelStripeSubscription(tenantSubscriptionProduct?.stripeSubscriptionId);
const stripeSubscription = await getStripeSubscription(tenantSubscriptionProduct.stripeSubscriptionId);
await cancelTenantSubscriptionProduct(tenantSubscriptionProduct.id, tenantId, {
cancelledAt: new Date(),
endsAt: stripeSubscription?.items.data[0]?.current_period_end ? new Date((stripeSubscription.items.data[0]?.current_period_end ?? 0) * 1000) : new Date(),
});
}