import { query, mutation, internalMutation } from "./_generated/server"; import { v } from "convex/values"; import { requireAuth } from "./helpers"; // ============================================================================ // Tier-Konfiguration // ============================================================================ export const TIER_CONFIG = { free: { monthlyCredits: 50, // €0,50 in Cent dailyGenerationCap: 10, concurrencyLimit: 1, premiumModels: false, topUpLimit: 0, // Kein Top-Up für Free }, starter: { monthlyCredits: 630, // €6,30 in Cent dailyGenerationCap: 50, concurrencyLimit: 2, premiumModels: true, topUpLimit: 2000, // €20 pro Monat }, pro: { monthlyCredits: 3602, // €36,02 in Cent (+5% Bonus) dailyGenerationCap: 200, concurrencyLimit: 2, premiumModels: true, topUpLimit: 10000, // €100 pro Monat }, business: { monthlyCredits: 7623, // €76,23 in Cent (+10% Bonus) dailyGenerationCap: 500, concurrencyLimit: 2, premiumModels: true, topUpLimit: 50000, // €500 pro Monat }, } as const; export type Tier = keyof typeof TIER_CONFIG; // ============================================================================ // Queries // ============================================================================ /** * Credit-Balance des eingeloggten Users abrufen. * Gibt balance, reserved und computed available zurück. */ export const getBalance = query({ args: {}, handler: async (ctx) => { const user = await requireAuth(ctx); const balance = await ctx.db .query("creditBalances") .withIndex("by_user", (q) => q.eq("userId", user.userId)) .unique(); if (!balance) { return { balance: 0, reserved: 0, available: 0, monthlyAllocation: 0 }; } return { balance: balance.balance, reserved: balance.reserved, available: balance.balance - balance.reserved, monthlyAllocation: balance.monthlyAllocation, }; }, }); /** * Letzte Transaktionen des Users abrufen. */ export const listTransactions = query({ args: { limit: v.optional(v.number()) }, handler: async (ctx, { limit }) => { const user = await requireAuth(ctx); return await ctx.db .query("creditTransactions") .withIndex("by_user", (q) => q.eq("userId", user.userId)) .order("desc") .take(limit ?? 50); }, }); /** * Aktuelle Subscription des Users abrufen. */ export const getSubscription = query({ args: {}, handler: async (ctx) => { const user = await requireAuth(ctx); return await ctx.db .query("subscriptions") .withIndex("by_user", (q) => q.eq("userId", user.userId)) .order("desc") .first(); }, }); /** * Heutige Nutzung des Users abrufen (für Abuse Prevention). */ export const getDailyUsage = query({ args: {}, handler: async (ctx) => { const user = await requireAuth(ctx); const today = new Date().toISOString().split("T")[0]; // "2026-03-25" const usage = await ctx.db .query("dailyUsage") .withIndex("by_user_date", (q) => q.eq("userId", user.userId).eq("date", today) ) .unique(); return usage ?? { generationCount: 0, concurrentJobs: 0 }; }, }); // ============================================================================ // Mutations — Credit Balance Management // ============================================================================ /** * Credit-Balance für einen neuen User initialisieren. * Wird beim ersten Login / Signup aufgerufen. */ export const initBalance = mutation({ args: {}, handler: async (ctx) => { const user = await requireAuth(ctx); // Prüfen ob schon existiert const existing = await ctx.db .query("creditBalances") .withIndex("by_user", (q) => q.eq("userId", user.userId)) .unique(); if (existing) return existing._id; // Free-Tier Credits als Startguthaben const balanceId = await ctx.db.insert("creditBalances", { userId: user.userId, balance: TIER_CONFIG.free.monthlyCredits, reserved: 0, monthlyAllocation: TIER_CONFIG.free.monthlyCredits, updatedAt: Date.now(), }); // Initiale Subscription (Free) await ctx.db.insert("subscriptions", { userId: user.userId, tier: "free", status: "active", currentPeriodStart: Date.now(), currentPeriodEnd: Date.now() + 30 * 24 * 60 * 60 * 1000, // +30 Tage }); // Initiale Transaktion loggen await ctx.db.insert("creditTransactions", { userId: user.userId, amount: TIER_CONFIG.free.monthlyCredits, type: "subscription", status: "committed", description: "Startguthaben — Free Tier", }); return balanceId; }, }); // ============================================================================ // Mutations — Reservation + Commit (Kern des Credit-Systems) // ============================================================================ /** * Credits reservieren — vor einem KI-Call. * * Prüft: ausreichend verfügbare Credits, Daily Cap, Concurrency Limit. * Gibt die Transaction-ID zurück (wird zum Commit/Release benötigt). */ export const reserve = mutation({ args: { estimatedCost: v.number(), // Geschätzte Kosten in Cent description: v.string(), nodeId: v.optional(v.id("nodes")), canvasId: v.optional(v.id("canvases")), model: v.optional(v.string()), }, handler: async (ctx, args) => { const user = await requireAuth(ctx); // Balance laden const balance = await ctx.db .query("creditBalances") .withIndex("by_user", (q) => q.eq("userId", user.userId)) .unique(); if (!balance) throw new Error("No credit balance found. Call initBalance first."); const available = balance.balance - balance.reserved; if (available < args.estimatedCost) { throw new Error( `Insufficient credits. Available: ${available}, required: ${args.estimatedCost}` ); } // Subscription laden für Tier-Checks const subscription = await ctx.db .query("subscriptions") .withIndex("by_user", (q) => q.eq("userId", user.userId)) .order("desc") .first(); const tier = (subscription?.tier ?? "free") as Tier; const config = TIER_CONFIG[tier]; // Daily Cap prüfen const today = new Date().toISOString().split("T")[0]; const dailyUsage = await ctx.db .query("dailyUsage") .withIndex("by_user_date", (q) => q.eq("userId", user.userId).eq("date", today) ) .unique(); if (dailyUsage && dailyUsage.generationCount >= config.dailyGenerationCap) { throw new Error( `Daily generation limit reached (${config.dailyGenerationCap}/${tier})` ); } // Concurrency Limit prüfen if (dailyUsage && dailyUsage.concurrentJobs >= config.concurrencyLimit) { throw new Error( `Concurrent job limit reached (${config.concurrencyLimit}/${tier})` ); } // Credits reservieren await ctx.db.patch(balance._id, { reserved: balance.reserved + args.estimatedCost, updatedAt: Date.now(), }); // Daily Usage aktualisieren if (dailyUsage) { await ctx.db.patch(dailyUsage._id, { generationCount: dailyUsage.generationCount + 1, concurrentJobs: dailyUsage.concurrentJobs + 1, }); } else { await ctx.db.insert("dailyUsage", { userId: user.userId, date: today, generationCount: 1, concurrentJobs: 1, }); } // Reservation-Transaktion erstellen const transactionId = await ctx.db.insert("creditTransactions", { userId: user.userId, amount: -args.estimatedCost, type: "reservation", status: "reserved", description: args.description, nodeId: args.nodeId, canvasId: args.canvasId, model: args.model, }); return transactionId; }, }); /** * Reservation committen — nach erfolgreichem KI-Call. * * Schreibt die tatsächlichen Kosten ab (können von Reservation abweichen). */ export const commit = mutation({ args: { transactionId: v.id("creditTransactions"), actualCost: v.number(), // Tatsächliche Kosten in Cent openRouterCost: v.optional(v.number()), // Echte API-Kosten }, handler: async (ctx, { transactionId, actualCost, openRouterCost }) => { const user = await requireAuth(ctx); const transaction = await ctx.db.get(transactionId); if (!transaction || transaction.userId !== user.userId) { throw new Error("Transaction not found"); } if (transaction.status !== "reserved") { throw new Error(`Transaction is ${transaction.status}, expected reserved`); } const estimatedCost = Math.abs(transaction.amount); // Balance aktualisieren const balance = await ctx.db .query("creditBalances") .withIndex("by_user", (q) => q.eq("userId", user.userId)) .unique(); if (!balance) throw new Error("No credit balance found"); await ctx.db.patch(balance._id, { balance: balance.balance - actualCost, reserved: balance.reserved - estimatedCost, updatedAt: Date.now(), }); // Transaktion committen await ctx.db.patch(transactionId, { amount: -actualCost, type: "usage", status: "committed", openRouterCost, }); // Concurrent Jobs dekrementieren const today = new Date().toISOString().split("T")[0]; const dailyUsage = await ctx.db .query("dailyUsage") .withIndex("by_user_date", (q) => q.eq("userId", user.userId).eq("date", today) ) .unique(); if (dailyUsage && dailyUsage.concurrentJobs > 0) { await ctx.db.patch(dailyUsage._id, { concurrentJobs: dailyUsage.concurrentJobs - 1, }); } }, }); /** * Reservation freigeben — bei fehlgeschlagenem KI-Call. * * Reservierte Credits werden komplett zurückgegeben. */ export const release = mutation({ args: { transactionId: v.id("creditTransactions"), }, handler: async (ctx, { transactionId }) => { const user = await requireAuth(ctx); const transaction = await ctx.db.get(transactionId); if (!transaction || transaction.userId !== user.userId) { throw new Error("Transaction not found"); } if (transaction.status !== "reserved") { throw new Error(`Transaction is ${transaction.status}, expected reserved`); } const estimatedCost = Math.abs(transaction.amount); // Credits freigeben const balance = await ctx.db .query("creditBalances") .withIndex("by_user", (q) => q.eq("userId", user.userId)) .unique(); if (!balance) throw new Error("No credit balance found"); await ctx.db.patch(balance._id, { reserved: balance.reserved - estimatedCost, updatedAt: Date.now(), }); // Transaktion als released markieren await ctx.db.patch(transactionId, { status: "released", }); // Concurrent Jobs dekrementieren const today = new Date().toISOString().split("T")[0]; const dailyUsage = await ctx.db .query("dailyUsage") .withIndex("by_user_date", (q) => q.eq("userId", user.userId).eq("date", today) ) .unique(); if (dailyUsage && dailyUsage.concurrentJobs > 0) { await ctx.db.patch(dailyUsage._id, { concurrentJobs: dailyUsage.concurrentJobs - 1, }); } // Generation Count NICHT zurücksetzen — der Versuch zählt }, }); // ============================================================================ // Mutations — Subscription & Top-Up (von Lemon Squeezy Webhooks aufgerufen) // ============================================================================ /** * Subscription aktivieren / ändern. * Wird vom Lemon Squeezy Webhook aufgerufen. */ export const activateSubscription = internalMutation({ args: { userId: v.string(), tier: v.union( v.literal("free"), v.literal("starter"), v.literal("pro"), v.literal("business") ), lemonSqueezySubscriptionId: v.string(), lemonSqueezyCustomerId: v.string(), currentPeriodStart: v.number(), currentPeriodEnd: v.number(), }, handler: async (ctx, args) => { const config = TIER_CONFIG[args.tier]; // Bestehende Subscription deaktivieren const existing = await ctx.db .query("subscriptions") .withIndex("by_user", (q) => q.eq("userId", args.userId)) .order("desc") .first(); if (existing) { await ctx.db.patch(existing._id, { status: "cancelled" }); } // Neue Subscription erstellen await ctx.db.insert("subscriptions", { userId: args.userId, tier: args.tier, status: "active", currentPeriodStart: args.currentPeriodStart, currentPeriodEnd: args.currentPeriodEnd, lemonSqueezySubscriptionId: args.lemonSqueezySubscriptionId, lemonSqueezyCustomerId: args.lemonSqueezyCustomerId, }); // Credits gutschreiben const balance = await ctx.db .query("creditBalances") .withIndex("by_user", (q) => q.eq("userId", args.userId)) .unique(); if (balance) { await ctx.db.patch(balance._id, { balance: balance.balance + config.monthlyCredits, monthlyAllocation: config.monthlyCredits, updatedAt: Date.now(), }); } else { await ctx.db.insert("creditBalances", { userId: args.userId, balance: config.monthlyCredits, reserved: 0, monthlyAllocation: config.monthlyCredits, updatedAt: Date.now(), }); } // Transaktion loggen await ctx.db.insert("creditTransactions", { userId: args.userId, amount: config.monthlyCredits, type: "subscription", status: "committed", description: `Abo-Gutschrift — ${args.tier} Tier`, }); }, }); /** * Credits nachkaufen (Top-Up). */ export const topUp = mutation({ args: { amount: v.number(), // Betrag in Cent }, handler: async (ctx, { amount }) => { const user = await requireAuth(ctx); if (amount <= 0) throw new Error("Amount must be positive"); // Tier-Limit prüfen const subscription = await ctx.db .query("subscriptions") .withIndex("by_user", (q) => q.eq("userId", user.userId)) .order("desc") .first(); const tier = (subscription?.tier ?? "free") as Tier; const config = TIER_CONFIG[tier]; if (config.topUpLimit === 0) { throw new Error("Top-up not available for Free tier"); } // Monatliches Top-Up-Limit prüfen const monthStart = new Date(); monthStart.setDate(1); monthStart.setHours(0, 0, 0, 0); const monthlyTopUps = await ctx.db .query("creditTransactions") .withIndex("by_user_type", (q) => q.eq("userId", user.userId).eq("type", "topup") ) .collect(); const thisMonthTopUps = monthlyTopUps .filter((t) => t._creationTime >= monthStart.getTime()) .reduce((sum, t) => sum + t.amount, 0); if (thisMonthTopUps + amount > config.topUpLimit) { throw new Error( `Monthly top-up limit reached. Limit: ${config.topUpLimit}, used: ${thisMonthTopUps}` ); } // Credits gutschreiben const balance = await ctx.db .query("creditBalances") .withIndex("by_user", (q) => q.eq("userId", user.userId)) .unique(); if (!balance) throw new Error("No credit balance found"); await ctx.db.patch(balance._id, { balance: balance.balance + amount, updatedAt: Date.now(), }); await ctx.db.insert("creditTransactions", { userId: user.userId, amount, type: "topup", status: "committed", description: `Credit-Nachkauf — ${(amount / 100).toFixed(2)}€`, }); }, });