Files
firecrawl/apps/api/src/services/billing/credit_billing.ts
T

544 lines
16 KiB
TypeScript
Raw Normal View History

2024-06-05 13:20:26 -07:00
import { NotificationType } from "../../types";
import { withAuth } from "../../lib/withAuth";
2024-06-05 13:20:26 -07:00
import { sendNotification } from "../notification/email_notification";
2024-04-15 17:01:47 -04:00
import { supabase_service } from "../supabase";
2024-07-23 17:30:46 -03:00
import { Logger } from "../../lib/logger";
2024-07-30 18:59:35 -04:00
import { getValue, setValue } from "../redis";
2024-08-12 13:37:47 -04:00
import { redlock } from "../redlock";
2024-09-04 15:19:45 -03:00
import * as Sentry from "@sentry/node";
2024-07-30 18:59:35 -04:00
2024-08-12 13:37:47 -04:00
const FREE_CREDITS = 500;
2024-07-30 18:59:35 -04:00
2024-04-15 17:01:47 -04:00
export async function billTeam(team_id: string, credits: number) {
return withAuth(supaBillTeam)(team_id, credits);
}
export async function supaBillTeam(team_id: string, credits: number) {
2024-04-15 17:01:47 -04:00
if (team_id === "preview") {
return { success: true, message: "Preview team, no credits used" };
}
2024-07-23 17:30:46 -03:00
Logger.info(`Billing team ${team_id} for ${credits} credits`);
2024-04-15 17:01:47 -04:00
// When the API is used, you can log the credit usage in the credit_usage table:
// team_id: The ID of the team using the API.
// subscription_id: The ID of the team's active subscription.
// credits_used: The number of credits consumed by the API call.
// created_at: The timestamp of the API usage.
2024-07-22 18:30:58 -04:00
// 1. get the subscription and check for available coupons concurrently
const [{ data: subscription }, { data: coupons }] = await Promise.all([
supabase_service
.from("subscriptions")
.select("*")
.eq("team_id", team_id)
.eq("status", "active")
.single(),
supabase_service
.from("coupons")
.select("id, credits")
.eq("team_id", team_id)
.eq("status", "active"),
]);
2024-04-25 10:05:53 -03:00
2024-04-26 11:42:49 -03:00
let couponCredits = 0;
let sortedCoupons = [];
2024-04-25 10:05:53 -03:00
if (coupons && coupons.length > 0) {
2024-06-05 13:22:03 -07:00
couponCredits = coupons.reduce(
(total, coupon) => total + coupon.credits,
0
);
sortedCoupons = [...coupons].sort((a, b) => b.credits - a.credits);
2024-04-25 10:05:53 -03:00
}
2024-04-26 11:42:49 -03:00
// using coupon credits:
if (couponCredits > 0) {
2024-04-26 14:36:19 -07:00
// if there is no subscription and they have enough coupon credits
2024-04-26 16:27:31 -03:00
if (!subscription) {
// using only coupon credits:
2024-04-26 14:36:19 -07:00
// if there are enough coupon credits
2024-04-26 16:27:31 -03:00
if (couponCredits >= credits) {
// remove credits from coupon credits
let usedCredits = credits;
while (usedCredits > 0) {
// update coupons
if (sortedCoupons[0].credits < usedCredits) {
usedCredits = usedCredits - sortedCoupons[0].credits;
// update coupon credits
await supabase_service
2024-06-05 13:22:03 -07:00
.from("coupons")
.update({
credits: 0,
})
.eq("id", sortedCoupons[0].id);
2024-04-26 16:27:31 -03:00
sortedCoupons.shift();
} else {
// update coupon credits
await supabase_service
2024-06-05 13:22:03 -07:00
.from("coupons")
.update({
credits: sortedCoupons[0].credits - usedCredits,
})
.eq("id", sortedCoupons[0].id);
2024-04-26 16:27:31 -03:00
usedCredits = 0;
}
}
return await createCreditUsage({ team_id, credits: 0 });
2024-06-05 13:22:03 -07:00
// not enough coupon credits and no subscription
2024-04-26 16:27:31 -03:00
} else {
// update coupon credits
const usedCredits = credits - couponCredits;
for (let i = 0; i < sortedCoupons.length; i++) {
await supabase_service
.from("coupons")
.update({
2024-06-05 13:22:03 -07:00
credits: 0,
2024-04-26 16:27:31 -03:00
})
.eq("id", sortedCoupons[i].id);
}
return await createCreditUsage({ team_id, credits: usedCredits });
}
}
2024-06-05 13:22:03 -07:00
2024-04-26 16:27:31 -03:00
// with subscription
// using coupon + subscription credits:
if (credits > couponCredits) {
// update coupon credits
for (let i = 0; i < sortedCoupons.length; i++) {
await supabase_service
.from("coupons")
.update({
2024-06-05 13:22:03 -07:00
credits: 0,
2024-04-26 16:27:31 -03:00
})
.eq("id", sortedCoupons[i].id);
}
const usedCredits = credits - couponCredits;
2024-06-05 13:22:03 -07:00
return await createCreditUsage({
team_id,
subscription_id: subscription.id,
credits: usedCredits,
});
} else {
// using only coupon credits
2024-04-26 11:42:49 -03:00
let usedCredits = credits;
while (usedCredits > 0) {
// update coupons
if (sortedCoupons[0].credits < usedCredits) {
usedCredits = usedCredits - sortedCoupons[0].credits;
// update coupon credits
await supabase_service
2024-06-05 13:22:03 -07:00
.from("coupons")
.update({
credits: 0,
})
.eq("id", sortedCoupons[0].id);
2024-04-26 11:42:49 -03:00
sortedCoupons.shift();
} else {
// update coupon credits
await supabase_service
2024-06-05 13:22:03 -07:00
.from("coupons")
.update({
credits: sortedCoupons[0].credits - usedCredits,
})
.eq("id", sortedCoupons[0].id);
2024-04-26 11:42:49 -03:00
usedCredits = 0;
}
}
2024-06-05 13:22:03 -07:00
return await createCreditUsage({
team_id,
subscription_id: subscription.id,
credits: 0,
});
2024-04-26 11:42:49 -03:00
}
}
2024-04-15 17:01:47 -04:00
2024-04-26 11:42:49 -03:00
// not using coupon credits
if (!subscription) {
return await createCreditUsage({ team_id, credits });
}
2024-06-05 13:22:03 -07:00
return await createCreditUsage({
team_id,
subscription_id: subscription.id,
credits,
});
2024-04-15 17:01:47 -04:00
}
export async function checkTeamCredits(team_id: string, credits: number) {
return withAuth(supaCheckTeamCredits)(team_id, credits);
}
// if team has enough credits for the operation, return true, else return false
export async function supaCheckTeamCredits(team_id: string, credits: number) {
2024-04-15 17:01:47 -04:00
if (team_id === "preview") {
return { success: true, message: "Preview team, no credits used", remainingCredits: Infinity };
2024-04-15 17:01:47 -04:00
}
2024-09-04 15:19:45 -03:00
let cacheKeySubscription = `subscription_${team_id}`;
let cacheKeyCoupons = `coupons_${team_id}`;
// Try to get data from cache first
const [cachedSubscription, cachedCoupons] = await Promise.all([
getValue(cacheKeySubscription),
getValue(cacheKeyCoupons)
]);
2024-09-07 13:42:45 -03:00
let subscription, subscriptionError;
let coupons : {credits: number}[];
2024-09-04 15:19:45 -03:00
if (cachedSubscription && cachedCoupons) {
subscription = JSON.parse(cachedSubscription);
coupons = JSON.parse(cachedCoupons);
} else {
// If not in cache, retrieve from database
const [subscriptionResult, couponsResult] = await Promise.all([
2024-07-22 18:30:58 -04:00
supabase_service
.from("subscriptions")
.select("id, price_id, current_period_start, current_period_end")
.eq("team_id", team_id)
.eq("status", "active")
.single(),
supabase_service
.from("coupons")
.select("credits")
.eq("team_id", team_id)
.eq("status", "active"),
]);
2024-04-15 17:01:47 -04:00
2024-09-04 15:19:45 -03:00
subscription = subscriptionResult.data;
subscriptionError = subscriptionResult.error;
coupons = couponsResult.data;
// Cache the results for a minute, sub can be null and that's fine
await setValue(cacheKeySubscription, JSON.stringify(subscription), 60); // Cache for 1 minute, even if null
2024-09-04 16:54:20 -03:00
await setValue(cacheKeyCoupons, JSON.stringify(coupons), 60); // Cache for 1 minute
2024-09-04 15:19:45 -03:00
}
2024-04-26 11:42:49 -03:00
let couponCredits = 0;
2024-04-25 10:05:53 -03:00
if (coupons && coupons.length > 0) {
2024-06-05 13:22:03 -07:00
couponCredits = coupons.reduce(
(total, coupon) => total + coupon.credits,
0
);
2024-04-26 11:42:49 -03:00
}
2024-09-07 13:42:45 -03:00
// If there are available coupons and they are enough for the operation
if (couponCredits >= credits) {
return { success: true, message: "Sufficient credits available", remainingCredits: couponCredits };
}
2024-08-30 12:34:45 -03:00
2024-08-30 12:05:37 -03:00
2024-04-26 16:27:31 -03:00
// Free credits, no coupons
2024-08-30 12:34:45 -03:00
if (!subscription || subscriptionError) {
let creditUsages;
let creditUsageError;
2024-09-04 15:19:45 -03:00
let totalCreditsUsed = 0;
const cacheKeyCreditUsage = `credit_usage_${team_id}`;
// Try to get credit usage from cache
const cachedCreditUsage = await getValue(cacheKeyCreditUsage);
if (cachedCreditUsage) {
totalCreditsUsed = parseInt(cachedCreditUsage);
} else {
let retries = 0;
const maxRetries = 3;
const retryInterval = 2000; // 2 seconds
while (retries < maxRetries) {
2024-09-07 13:42:45 -03:00
// Reminder, this has an 1000 limit.
2024-09-04 15:19:45 -03:00
const result = await supabase_service
.from("credit_usage")
.select("credits_used")
.is("subscription_id", null)
.eq("team_id", team_id);
creditUsages = result.data;
creditUsageError = result.error;
if (!creditUsageError) {
break;
}
retries++;
if (retries < maxRetries) {
await new Promise(resolve => setTimeout(resolve, retryInterval));
}
2024-08-30 12:34:45 -03:00
}
2024-09-04 15:19:45 -03:00
if (creditUsageError) {
Logger.error(`Credit usage error after ${maxRetries} attempts: ${creditUsageError}`);
throw new Error(
`Failed to retrieve credit usage for team_id: ${team_id}`
);
2024-08-30 12:34:45 -03:00
}
2024-09-04 15:19:45 -03:00
totalCreditsUsed = creditUsages.reduce(
(acc, usage) => acc + usage.credits_used,
0
2024-04-26 16:27:31 -03:00
);
2024-09-04 15:19:45 -03:00
// Cache the result for 30 seconds
await setValue(cacheKeyCreditUsage, totalCreditsUsed.toString(), 30);
}
2024-04-26 11:42:49 -03:00
2024-07-23 17:30:46 -03:00
Logger.info(`totalCreditsUsed: ${totalCreditsUsed}`);
2024-06-05 13:20:26 -07:00
const end = new Date();
2024-06-05 13:22:03 -07:00
end.setDate(end.getDate() + 30);
2024-06-05 13:20:26 -07:00
// check if usage is within 80% of the limit
const creditLimit = FREE_CREDITS;
2024-09-07 13:42:45 -03:00
const creditUsagePercentage = totalCreditsUsed / creditLimit;
2024-06-05 13:20:26 -07:00
// Add a check to ensure totalCreditsUsed is greater than 0
if (totalCreditsUsed > 0 && creditUsagePercentage >= 0.8 && creditUsagePercentage < 1) {
Logger.info(`Sending notification for team ${team_id}. Total credits used: ${totalCreditsUsed}, Credit usage percentage: ${creditUsagePercentage}`);
2024-06-05 13:22:03 -07:00
await sendNotification(
team_id,
NotificationType.APPROACHING_LIMIT,
new Date().toISOString(),
end.toISOString()
);
2024-06-05 13:20:26 -07:00
}
2024-04-26 16:27:31 -03:00
// 5. Compare the total credits used with the credits allowed by the plan.
2024-09-08 13:07:10 -03:00
if (totalCreditsUsed >= FREE_CREDITS) {
2024-06-05 13:20:26 -07:00
// Send email notification for insufficient credits
2024-06-05 13:22:03 -07:00
await sendNotification(
team_id,
NotificationType.LIMIT_REACHED,
new Date().toISOString(),
end.toISOString()
);
2024-04-26 16:27:31 -03:00
return {
success: false,
message: "Insufficient credits, please upgrade!",
remainingCredits: FREE_CREDITS - totalCreditsUsed
2024-04-26 16:27:31 -03:00
};
}
return { success: true, message: "Sufficient credits available", remainingCredits: FREE_CREDITS - totalCreditsUsed };
2024-04-15 17:01:47 -04:00
}
2024-05-09 15:29:58 -07:00
let totalCreditsUsed = 0;
2024-07-30 18:59:35 -04:00
const cacheKey = `credit_usage_${subscription.id}_${subscription.current_period_start}_${subscription.current_period_end}_lc`;
const redLockKey = `lock_${cacheKey}`;
const lockTTL = 10000; // 10 seconds
2024-05-09 15:29:58 -07:00
try {
2024-07-30 18:59:35 -04:00
const lock = await redlock.acquire([redLockKey], lockTTL);
2024-04-15 17:01:47 -04:00
2024-07-30 18:59:35 -04:00
try {
const cachedCreditUsage = await getValue(cacheKey);
if (cachedCreditUsage) {
totalCreditsUsed = parseInt(cachedCreditUsage);
} else {
const { data: creditUsages, error: creditUsageError } =
await supabase_service.rpc("get_credit_usage_2", {
sub_id: subscription.id,
start_time: subscription.current_period_start,
end_time: subscription.current_period_end,
});
2024-04-25 10:05:53 -03:00
2024-07-30 18:59:35 -04:00
if (creditUsageError) {
Logger.error(`Error calculating credit usage: ${creditUsageError}`);
}
if (creditUsages && creditUsages.length > 0) {
totalCreditsUsed = creditUsages[0].total_credits_used;
2024-09-04 15:19:45 -03:00
await setValue(cacheKey, totalCreditsUsed.toString(), 500); // Cache for 8 minutes
2024-07-30 18:59:35 -04:00
// Logger.info(`Cache set for credit usage: ${totalCreditsUsed}`);
}
}
} finally {
await lock.release();
2024-05-09 15:29:58 -07:00
}
} catch (error) {
2024-07-30 18:59:35 -04:00
Logger.error(`Error acquiring lock or calculating credit usage: ${error}`);
2024-05-09 15:29:58 -07:00
}
2024-06-05 13:20:26 -07:00
2024-04-25 10:05:53 -03:00
// Adjust total credits used by subtracting coupon value
2024-04-26 11:42:49 -03:00
const adjustedCreditsUsed = Math.max(0, totalCreditsUsed - couponCredits);
2024-09-04 15:19:45 -03:00
// Get the price details from cache or database
const priceCacheKey = `price_${subscription.price_id}`;
2024-09-07 13:42:45 -03:00
let price : {credits: number};
2024-09-04 15:19:45 -03:00
try {
const cachedPrice = await getValue(priceCacheKey);
if (cachedPrice) {
price = JSON.parse(cachedPrice);
} else {
const { data, error: priceError } = await supabase_service
.from("prices")
.select("credits")
.eq("id", subscription.price_id)
.single();
if (priceError) {
throw new Error(
`Failed to retrieve price for price_id: ${subscription.price_id}`
);
}
price = data;
// There are only 21 records, so this is super fine
// Cache the price for a long time (e.g., 1 day)
await setValue(priceCacheKey, JSON.stringify(price), 86400);
}
} catch (error) {
Logger.error(`Error retrieving or caching price: ${error}`);
Sentry.captureException(error);
// If errors, just assume it's a big number so user don't get an error
2024-09-07 13:42:45 -03:00
price = { credits: 10000000 };
2024-04-25 10:05:53 -03:00
}
2024-06-05 13:20:26 -07:00
const creditLimit = price.credits;
2024-09-07 13:42:45 -03:00
// Removal of + credits
const creditUsagePercentage = adjustedCreditsUsed / creditLimit;
2024-06-05 13:20:26 -07:00
2024-04-25 10:05:53 -03:00
// Compare the adjusted total credits used with the credits allowed by the plan
2024-09-08 13:07:10 -03:00
if (adjustedCreditsUsed >= price.credits) {
2024-09-07 13:42:45 -03:00
await sendNotification(
team_id,
NotificationType.LIMIT_REACHED,
subscription.current_period_start,
subscription.current_period_end
);
return { success: false, message: "Insufficient credits, please upgrade!", remainingCredits: creditLimit - adjustedCreditsUsed };
2024-09-07 13:42:45 -03:00
} else if (creditUsagePercentage >= 0.8 && creditUsagePercentage < 1) {
2024-06-05 13:20:26 -07:00
// Send email notification for approaching credit limit
2024-09-07 13:42:45 -03:00
await sendNotification(
team_id,
NotificationType.APPROACHING_LIMIT,
subscription.current_period_start,
subscription.current_period_end
);
2024-04-15 17:01:47 -04:00
}
return { success: true, message: "Sufficient credits available", remainingCredits: creditLimit - adjustedCreditsUsed };
2024-04-15 17:01:47 -04:00
}
// Count the total credits used by a team within the current billing period and return the remaining credits.
export async function countCreditsAndRemainingForCurrentBillingPeriod(
team_id: string
) {
// 1. Retrieve the team's active subscription based on the team_id.
const { data: subscription, error: subscriptionError } =
await supabase_service
.from("subscriptions")
.select("id, price_id, current_period_start, current_period_end")
.eq("team_id", team_id)
.single();
2024-04-26 11:42:49 -03:00
const { data: coupons } = await supabase_service
.from("coupons")
.select("credits")
.eq("team_id", team_id)
.eq("status", "active");
2024-04-25 10:05:53 -03:00
2024-04-26 11:42:49 -03:00
let couponCredits = 0;
if (coupons && coupons.length > 0) {
2024-06-05 13:22:03 -07:00
couponCredits = coupons.reduce(
(total, coupon) => total + coupon.credits,
0
);
2024-04-26 11:42:49 -03:00
}
2024-04-15 17:01:47 -04:00
2024-04-26 11:42:49 -03:00
if (subscriptionError || !subscription) {
2024-04-15 17:01:47 -04:00
// Free
const { data: creditUsages, error: creditUsageError } =
await supabase_service
.from("credit_usage")
.select("credits_used")
.is("subscription_id", null)
.eq("team_id", team_id);
if (creditUsageError || !creditUsages) {
2024-06-05 13:22:03 -07:00
throw new Error(
`Failed to retrieve credit usage for team_id: ${team_id}`
);
2024-04-15 17:01:47 -04:00
}
const totalCreditsUsed = creditUsages.reduce(
(acc, usage) => acc + usage.credits_used,
0
);
2024-04-26 11:42:49 -03:00
const remainingCredits = FREE_CREDITS + couponCredits - totalCreditsUsed;
2024-06-05 13:22:03 -07:00
return {
totalCreditsUsed: totalCreditsUsed,
remainingCredits,
totalCredits: FREE_CREDITS + couponCredits,
};
2024-04-15 17:01:47 -04:00
}
const { data: creditUsages, error: creditUsageError } = await supabase_service
2024-04-26 11:42:49 -03:00
.from("credit_usage")
.select("credits_used")
.eq("subscription_id", subscription.id)
.gte("created_at", subscription.current_period_start)
.lte("created_at", subscription.current_period_end);
2024-04-15 17:01:47 -04:00
if (creditUsageError || !creditUsages) {
2024-06-05 13:22:03 -07:00
throw new Error(
`Failed to retrieve credit usage for subscription_id: ${subscription.id}`
);
2024-04-15 17:01:47 -04:00
}
2024-06-05 13:22:03 -07:00
const totalCreditsUsed = creditUsages.reduce(
(acc, usage) => acc + usage.credits_used,
0
);
2024-04-15 17:01:47 -04:00
2024-04-25 10:05:53 -03:00
const { data: price, error: priceError } = await supabase_service
2024-04-26 11:42:49 -03:00
.from("prices")
.select("credits")
.eq("id", subscription.price_id)
.single();
2024-04-25 10:05:53 -03:00
if (priceError || !price) {
2024-06-05 13:22:03 -07:00
throw new Error(
`Failed to retrieve price for price_id: ${subscription.price_id}`
);
2024-04-25 10:05:53 -03:00
}
2024-04-26 11:42:49 -03:00
const remainingCredits = price.credits + couponCredits - totalCreditsUsed;
2024-04-25 10:05:53 -03:00
return {
2024-04-26 11:42:49 -03:00
totalCreditsUsed,
remainingCredits,
2024-06-05 13:22:03 -07:00
totalCredits: price.credits,
2024-04-25 10:05:53 -03:00
};
2024-04-26 11:42:49 -03:00
}
2024-06-05 13:22:03 -07:00
async function createCreditUsage({
team_id,
subscription_id,
credits,
}: {
team_id: string;
subscription_id?: string;
credits: number;
}) {
2024-09-03 21:09:32 -03:00
await supabase_service
.from("credit_usage")
2024-04-26 11:42:49 -03:00
.insert([
{
team_id,
credits_used: credits,
subscription_id: subscription_id || null,
created_at: new Date(),
},
2024-09-03 21:09:32 -03:00
]);
2024-04-26 11:42:49 -03:00
2024-09-03 21:09:32 -03:00
return { success: true };
2024-06-05 13:22:03 -07:00
}