181 lines
6.2 KiB
TypeScript
181 lines
6.2 KiB
TypeScript
import { PrismaClient } from '@prisma/client';
|
|
import { GoogleGenAI } from "@google/genai";
|
|
|
|
const prisma = new PrismaClient() as any;
|
|
|
|
// COST CONSTANTS (USD)
|
|
const COSTS = {
|
|
GENERATE_PROMPT: 0.001, // Gemini Pro Text
|
|
GENERATE_MASTER: 0.040, // Imagen 3 (High Res)
|
|
GENERATE_VARIANT: 0.020, // Imagen 3 (Standard/Fast)
|
|
GENERATE_MOCKUP: 0.020, // Imagen 3 (Standard/Fast)
|
|
REFINE_PROJECT: 0.040, // Imagen 3 (High Res) - treat same as Master
|
|
MOCKUP_REMOVAL: 0.005 // Background Removal (Hypothetical)
|
|
};
|
|
|
|
// CREDIT PRICES
|
|
const PRICES = {
|
|
GENERATE_PROMPT: 1,
|
|
GENERATE_MASTER: 10,
|
|
GENERATE_VARIANT: 5,
|
|
GENERATE_MOCKUP: 5,
|
|
REFINE_PROJECT: 10, // Same as Master
|
|
MOCKUP_REMOVAL: 2
|
|
};
|
|
|
|
export type ActionType = keyof typeof COSTS;
|
|
|
|
|
|
// CACHE: In-memory store for pricing config to reduce DB hits
|
|
// Default TTL: 60 seconds
|
|
let configCache: { [key: string]: number } | null = null;
|
|
let cacheExpiry = 0;
|
|
const CACHE_TTL_MS = 60 * 1000;
|
|
|
|
async function getPricing(action: ActionType): Promise<{ cost: number, credits: number }> {
|
|
const now = Date.now();
|
|
|
|
// 1. Refresh Cache if expired
|
|
if (!configCache || now > cacheExpiry) {
|
|
try {
|
|
const configs = await prisma.systemConfig.findMany();
|
|
configCache = {};
|
|
// Populate cache
|
|
configs.forEach((c: any) => {
|
|
const val = parseFloat(c.value);
|
|
if (!isNaN(val)) {
|
|
configCache![c.key] = val;
|
|
}
|
|
});
|
|
cacheExpiry = now + CACHE_TTL_MS;
|
|
// console.log("[UsageService] Pricing cache refreshed.");
|
|
} catch (e) {
|
|
console.error("[UsageService] Failed to fetch system config, using defaults.", e);
|
|
// If DB fails, fallback to empty cache (which triggers defaults below)
|
|
if (!configCache) configCache = {};
|
|
}
|
|
}
|
|
|
|
// 2. Resolve Values (DB > Default)
|
|
// Keys in DB: COST_GENERATE_MASTER, PRICE_GENERATE_MASTER
|
|
const costKey = `COST_${action}`;
|
|
const priceKey = `PRICE_${action}`;
|
|
|
|
const cost = (configCache && configCache[costKey] !== undefined)
|
|
? configCache[costKey]
|
|
: COSTS[action];
|
|
|
|
const credits = (configCache && configCache[priceKey] !== undefined)
|
|
? configCache[priceKey]
|
|
: PRICES[action];
|
|
|
|
return { cost, credits };
|
|
}
|
|
|
|
export const usageService = {
|
|
/**
|
|
* Deducts credits using DYNAMIC pricing.
|
|
*/
|
|
async deductCredits(userId: string, action: ActionType) {
|
|
const { cost, credits } = await getPricing(action);
|
|
|
|
return await prisma.$transaction(async (tx: any) => {
|
|
const user = await tx.user.findUnique({ where: { id: userId } });
|
|
if (!user) throw new Error("User not found");
|
|
|
|
// ADMIN OVERRIDE: Unlimited Credits
|
|
// God Mode: Admins bypass all checks.
|
|
if (user.role === 'ADMIN' || user.role === 'VIP') {
|
|
console.log(`[UsageService] ⚡ GOD MODE: User is ${user.role}, bypassing credit deduction.`);
|
|
return user;
|
|
}
|
|
|
|
if (user.credits < credits) {
|
|
// Determine if we should show the 'cost' in error message
|
|
throw new Error(`Insufficient credits for ${action}. Needed: ${credits}, Balance: ${user.credits}`);
|
|
}
|
|
|
|
const updatedUser = await tx.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
credits: { decrement: credits },
|
|
totalCost: { increment: cost }
|
|
}
|
|
});
|
|
|
|
await tx.usageLog.create({
|
|
data: {
|
|
userId,
|
|
action,
|
|
cost,
|
|
credits
|
|
}
|
|
});
|
|
|
|
return updatedUser;
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Helper to get current price for UI (without deducting)
|
|
*/
|
|
async getActionPrice(action: ActionType) {
|
|
return await getPricing(action);
|
|
},
|
|
|
|
async recordPurchase(userId: string, amountUSD: number, creditsGiven: number) {
|
|
return await prisma.$transaction(async (tx: any) => {
|
|
await tx.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
credits: { increment: creditsGiven },
|
|
totalRevenue: { increment: amountUSD }
|
|
}
|
|
});
|
|
|
|
await tx.transaction.create({
|
|
data: {
|
|
userId,
|
|
amount: amountUSD,
|
|
credits: creditsGiven,
|
|
type: "PURCHASE"
|
|
}
|
|
});
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Validates a Gemini API Key by making a minimal test call.
|
|
*/
|
|
async validateApiKey(apiKey: string): Promise<{ valid: boolean, error?: string, code?: string }> {
|
|
if (!apiKey || apiKey.length < 20) return { valid: false, error: "API Key is too short or missing.", code: "INVALID_FORMAT" };
|
|
|
|
try {
|
|
const genAI = new GoogleGenAI({ apiKey });
|
|
// Minimal token generation to test validity & quota
|
|
await genAI.models.generateContent({
|
|
model: "gemini-3-flash-preview", // Updated to a confirmed available model
|
|
contents: [{ role: "user", parts: [{ text: "Hi" }] }],
|
|
config: { maxOutputTokens: 1 }
|
|
});
|
|
|
|
return { valid: true };
|
|
} catch (error: any) {
|
|
console.warn("[UsageService] API Key Validation Failed:", error.message);
|
|
const msg = error.message || "";
|
|
|
|
if (msg.includes("403") || msg.includes("API key not valid")) {
|
|
return { valid: false, error: "Invalid API Key. Please check characters.", code: "INVALID_KEY" };
|
|
}
|
|
if (msg.includes("429") || msg.includes("quota")) {
|
|
return { valid: false, error: "API Key Quota Exceeded. You may need to enable billing.", code: "QUOTA_EXCEEDED" };
|
|
}
|
|
if (msg.includes("400")) {
|
|
return { valid: false, error: "Bad Request. Key might be malformed.", code: "BAD_REQUEST" };
|
|
}
|
|
|
|
return { valid: false, error: "Validator Error: " + msg, code: "UNKNOWN_ERROR" };
|
|
}
|
|
}
|
|
};
|