import { NotificationType } from "../../types"; import { withAuth } from "../../lib/withAuth"; import { sendNotification } from "../notification/email_notification"; import { supabase_service } from "../supabase"; import { Logger } from "../../lib/logger"; import { getValue, setValue } from "../redis"; import { redlock } from "../redlock"; const FREE_CREDITS = 500; export async function billTeam(team_id: string, credits: number) { return withAuth(supaBillTeam)(team_id, credits); } export async function supaBillTeam(team_id: string, credits: number) { if (team_id === "preview") { return { success: true, message: "Preview team, no credits used" }; } Logger.info(`Billing team ${team_id} for ${credits} credits`); // 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. // 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"), ]); let couponCredits = 0; if (coupons && coupons.length > 0) { couponCredits = coupons.reduce( (total, coupon) => total + coupon.credits, 0 ); } let sortedCoupons = coupons.sort((a, b) => b.credits - a.credits); // using coupon credits: if (couponCredits > 0) { // if there is no subscription and they have enough coupon credits if (!subscription) { // using only coupon credits: // if there are enough coupon credits 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 .from("coupons") .update({ credits: 0, }) .eq("id", sortedCoupons[0].id); sortedCoupons.shift(); } else { // update coupon credits await supabase_service .from("coupons") .update({ credits: sortedCoupons[0].credits - usedCredits, }) .eq("id", sortedCoupons[0].id); usedCredits = 0; } } return await createCreditUsage({ team_id, credits: 0 }); // not enough coupon credits and no subscription } else { // update coupon credits const usedCredits = credits - couponCredits; for (let i = 0; i < sortedCoupons.length; i++) { await supabase_service .from("coupons") .update({ credits: 0, }) .eq("id", sortedCoupons[i].id); } return await createCreditUsage({ team_id, credits: usedCredits }); } } // 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({ credits: 0, }) .eq("id", sortedCoupons[i].id); } const usedCredits = credits - couponCredits; return await createCreditUsage({ team_id, subscription_id: subscription.id, credits: usedCredits, }); } else { // using only 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 .from("coupons") .update({ credits: 0, }) .eq("id", sortedCoupons[0].id); sortedCoupons.shift(); } else { // update coupon credits await supabase_service .from("coupons") .update({ credits: sortedCoupons[0].credits - usedCredits, }) .eq("id", sortedCoupons[0].id); usedCredits = 0; } } return await createCreditUsage({ team_id, subscription_id: subscription.id, credits: 0, }); } } // not using coupon credits if (!subscription) { return await createCreditUsage({ team_id, credits }); } return await createCreditUsage({ team_id, subscription_id: subscription.id, credits, }); } 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) { if (team_id === "preview") { return { success: true, message: "Preview team, no credits used", remainingCredits: Infinity }; } // Retrieve the team's active subscription and check for available coupons concurrently const [{ data: subscription, error: subscriptionError }, { data: coupons }] = await Promise.all([ 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"), ]); let couponCredits = 0; if (coupons && coupons.length > 0) { couponCredits = coupons.reduce( (total, coupon) => total + coupon.credits, 0 ); } // Free credits, no coupons if (!subscription || subscriptionError) { // If there is no active subscription but there are available coupons if (couponCredits >= credits) { return { success: true, message: "Sufficient credits available", remainingCredits: couponCredits }; } let creditUsages; let creditUsageError; let retries = 0; const maxRetries = 3; const retryInterval = 2000; // 2 seconds while (retries < maxRetries) { 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)); } } if (creditUsageError) { Logger.error(`Credit usage error after ${maxRetries} attempts: ${creditUsageError}`); throw new Error( `Failed to retrieve credit usage for team_id: ${team_id}` ); } const totalCreditsUsed = creditUsages.reduce( (acc, usage) => acc + usage.credits_used, 0 ); Logger.info(`totalCreditsUsed: ${totalCreditsUsed}`); const end = new Date(); end.setDate(end.getDate() + 30); // check if usage is within 80% of the limit const creditLimit = FREE_CREDITS; const creditUsagePercentage = (totalCreditsUsed + credits) / creditLimit; if (creditUsagePercentage >= 0.8) { await sendNotification( team_id, NotificationType.APPROACHING_LIMIT, new Date().toISOString(), end.toISOString() ); } // 5. Compare the total credits used with the credits allowed by the plan. if (totalCreditsUsed + credits > FREE_CREDITS) { // Send email notification for insufficient credits await sendNotification( team_id, NotificationType.LIMIT_REACHED, new Date().toISOString(), end.toISOString() ); return { success: false, message: "Insufficient credits, please upgrade!", remainingCredits: FREE_CREDITS - totalCreditsUsed }; } return { success: true, message: "Sufficient credits available", remainingCredits: FREE_CREDITS - totalCreditsUsed }; } let totalCreditsUsed = 0; const cacheKey = `credit_usage_${subscription.id}_${subscription.current_period_start}_${subscription.current_period_end}_lc`; const redLockKey = `lock_${cacheKey}`; const lockTTL = 10000; // 10 seconds try { const lock = await redlock.acquire([redLockKey], lockTTL); 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, }); if (creditUsageError) { Logger.error(`Error calculating credit usage: ${creditUsageError}`); } if (creditUsages && creditUsages.length > 0) { totalCreditsUsed = creditUsages[0].total_credits_used; await setValue(cacheKey, totalCreditsUsed.toString(), 1800); // Cache for 30 minutes // Logger.info(`Cache set for credit usage: ${totalCreditsUsed}`); } } } finally { await lock.release(); } } catch (error) { Logger.error(`Error acquiring lock or calculating credit usage: ${error}`); } // Adjust total credits used by subtracting coupon value const adjustedCreditsUsed = Math.max(0, totalCreditsUsed - couponCredits); // Get the price details const { data: price, 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}` ); } const creditLimit = price.credits; const creditUsagePercentage = (adjustedCreditsUsed + credits) / creditLimit; // Compare the adjusted total credits used with the credits allowed by the plan if (adjustedCreditsUsed + credits > price.credits) { // 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 }; } else if (creditUsagePercentage >= 0.8) { // Send email notification for approaching credit limit // await sendNotification( // team_id, // NotificationType.APPROACHING_LIMIT, // subscription.current_period_start, // subscription.current_period_end // ); } return { success: true, message: "Sufficient credits available", remainingCredits: creditLimit - adjustedCreditsUsed }; } // 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(); const { data: coupons } = await supabase_service .from("coupons") .select("credits") .eq("team_id", team_id) .eq("status", "active"); let couponCredits = 0; if (coupons && coupons.length > 0) { couponCredits = coupons.reduce( (total, coupon) => total + coupon.credits, 0 ); } if (subscriptionError || !subscription) { // 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) { throw new Error( `Failed to retrieve credit usage for team_id: ${team_id}` ); } const totalCreditsUsed = creditUsages.reduce( (acc, usage) => acc + usage.credits_used, 0 ); const remainingCredits = FREE_CREDITS + couponCredits - totalCreditsUsed; return { totalCreditsUsed: totalCreditsUsed, remainingCredits, totalCredits: FREE_CREDITS + couponCredits, }; } const { data: creditUsages, error: creditUsageError } = await supabase_service .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); if (creditUsageError || !creditUsages) { throw new Error( `Failed to retrieve credit usage for subscription_id: ${subscription.id}` ); } const totalCreditsUsed = creditUsages.reduce( (acc, usage) => acc + usage.credits_used, 0 ); const { data: price, error: priceError } = await supabase_service .from("prices") .select("credits") .eq("id", subscription.price_id) .single(); if (priceError || !price) { throw new Error( `Failed to retrieve price for price_id: ${subscription.price_id}` ); } const remainingCredits = price.credits + couponCredits - totalCreditsUsed; return { totalCreditsUsed, remainingCredits, totalCredits: price.credits, }; } async function createCreditUsage({ team_id, subscription_id, credits, }: { team_id: string; subscription_id?: string; credits: number; }) { const { data: credit_usage } = await supabase_service .from("credit_usage") .insert([ { team_id, credits_used: credits, subscription_id: subscription_id || null, created_at: new Date(), }, ]) .select(); return { success: true, credit_usage }; }