Kit enthält ein vollständiges Credit-System für KI-intensive Anwendungen. Nutzer erhalten eine monatliche Credit-Zuteilung basierend auf ihrer Abonnement-Stufe, und Credits werden pro KI-Operation abgezogen. Das System verwendet atomare Datenbanktransaktionen, um Race Conditions zu verhindern, pflegt ein unveränderliches Ledger für Prüfpfade und unterstützt Bonus-Credit-Käufe als Aufladung.
Das Credit-System ist nur aktiv, wenn
NEXT_PUBLIC_PRICING_MODEL=credit_based gesetzt ist. Im klassischen SaaS-Modus sind alle KI-Operationen unbegrenzt (kein Credit-Tracking).Wie Credits funktionieren
Der Credit-Lebenszyklus folgt einem monatlichen Zyklus, der am Abrechnungszeitraum ausgerichtet ist:
Abonnement erstellt
|
v
Credits initialisiert (Stufen-Zuteilung)
|--- Free: 500 Credits
|--- Basic: 1.500 Credits
|--- Pro: 5.000 Credits
|--- Enterprise: 15.000 Credits
|
v
Nutzer führt KI-Operationen durch
|--- Jede Operation kostet Credits (5 - 80)
|--- Atomare Abbuchung mit SELECT FOR UPDATE
|--- Unveränderlicher CreditTransaction-Logeintrag
|
v
Monatlicher Reset (ausgelöst durch Abrechnungszahlung)
|--- subscription_payment_success-Webhook
|--- Credits auf Stufen-Zuteilung zurückgesetzt
|--- creditsResetAt auf NOW aktualisiert
|--- Automatischer Reset-Backup nach 30 Tagen
|
v
Zyklus wiederholt sich
Credit-Zuteilung nach Stufe
Jede Stufe erhält eine monatliche Credit-Zuteilung, konfigurierbar über Umgebungsvariablen:
| Stufe | Standard-Credits | Umgebungsvariable | Fallback |
|---|---|---|---|
| Free | 500 | CREDIT_SYSTEM_FREE_CREDITS | 500 |
| Basic | 1.500 | CREDIT_SYSTEM_BASIC_CREDITS | 1.500 |
| Pro | 5.000 | CREDIT_SYSTEM_PRO_CREDITS | 5.000 |
| Enterprise | 15.000 | CREDIT_SYSTEM_ENTERPRISE_CREDITS | 15.000 |
Die Funktion
getDefaultCreditsByTier() liest diese Werte mit Fallback-Schutz:src/lib/credits/config.ts — getDefaultCreditsByTier()
export function getDefaultCreditsByTier(tier: SubscriptionTier): number {
const tierCredits: CreditTierMap = {
free: parseCreditsWithFallback(process.env.CREDIT_SYSTEM_FREE_CREDITS, 500),
basic: parseCreditsWithFallback(
process.env.CREDIT_SYSTEM_BASIC_CREDITS,
1500
),
pro: parseCreditsWithFallback(process.env.CREDIT_SYSTEM_PRO_CREDITS, 5000),
enterprise: parseCreditsWithFallback(
process.env.CREDIT_SYSTEM_ENTERPRISE_CREDITS,
15000
),
}
return tierCredits[tier]
}
Credit-System-Feature-Flag
Das Credit-System aktiviert sich basierend auf der Umgebungsvariable des Preismodells. Die Funktion
isCreditSystemEnabled() ist die einzige Wahrheitsquelle:src/lib/credits/config.ts — isCreditSystemEnabled()
export function isCreditSystemEnabled(): boolean {
// Return cached value if available
if (creditSystemEnabledCache !== null) {
return creditSystemEnabledCache
}
// Check pricing model (credit_based = credits enabled, classic_saas = unlimited)
const pricingModel = process.env.NEXT_PUBLIC_PRICING_MODEL
// CRITICAL: Distinguish between test types (unit vs E2E)
// Unit tests (Vitest): ENABLE credit system to test credit logic
// - Set ENABLE_CREDIT_SYSTEM_IN_TESTS=true in vitest.config.ts
// - Mock Prisma directly for controlled testing
// E2E tests (Playwright): DISABLE credit system (no database in CI)
// - Do NOT set ENABLE_CREDIT_SYSTEM_IN_TESTS flag
// - Tests run without database write operations
const isUnitTest = process.env.ENABLE_CREDIT_SYSTEM_IN_TESTS === 'true'
const isE2ETest = process.env.NODE_ENV === 'test' && !isUnitTest
const enabled = pricingModel === 'credit_based' && !isE2ETest
// Cache the result
creditSystemEnabledCache = enabled
// Log status once in development
if (
process.env.NODE_ENV === 'development' &&
!globalThis.__creditSystemLoggedOnce
) {
console.log(
`[Credit System] ${enabled ? 'Enabled' : 'Disabled'} ` +
`(MODEL=${pricingModel || 'undefined'}, isUnitTest=${isUnitTest}, isE2ETest=${isE2ETest})`
)
globalThis.__creditSystemLoggedOnce = true
}
return enabled
}
Wenn das Credit-System deaktiviert ist (klassischer SaaS-Modus), geben alle Credit-Operationen Erfolg mit einem „unbegrenzten" Guthaben von 999.999 zurück. Das bedeutet, dein KI-Operationscode benötigt keine bedingten Prüfungen — er ruft immer
deductCredits() auf, und die Funktion verwaltet intern das Modell-Switching.Credit-Abbuchung
Atomare Operationen
Credit-Abbuchungen verwenden PostgreSQL
SELECT FOR UPDATE-Zeilenebenen-Sperren, um Race Conditions zu verhindern. Dies ist entscheidend für KI-Anwendungen, bei denen mehrere gleichzeitige Anfragen möglicherweise versuchen, Credits gleichzeitig abzubuchen.src/lib/credits/credit-manager.ts — deductCredits() with SELECT FOR UPDATE
export async function deductCredits(params: {
userId: string
operation: CreditOperation
quantity?: number
metadata?: Prisma.InputJsonValue
}): Promise<CreditOperationResult> {
const { userId, operation, quantity = 1, metadata } = params
// Calculate cost based on operation type and quantity
const cost = calculateBatchCost(operation, quantity)
console.log(
`[Credits] Deducting ${cost} credits for ${operation} (x${quantity})`
)
// Skip if credit system disabled
if (!isCreditSystemEnabled()) {
console.log('[Credits] System disabled - skipping deduction')
return {
success: true,
balance: 999999, // Unlimited
message: 'Credit system disabled',
}
}
try {
// Ensure credits initialized before deducting
await ensureUserCreditsInitializedSafe(userId)
// Use transaction to atomically check balance and deduct credits
const result = await prisma.$transaction(
async (tx) => {
// CRITICAL: Lock user row with SELECT FOR UPDATE to prevent race conditions
// This ensures only ONE transaction can read and modify the balance at a time
const userRows = await tx.$queryRaw<
Array<{ creditBalance: Decimal; creditsPerMonth: Decimal }>
>`
SELECT "creditBalance", "creditsPerMonth"
FROM "User"
WHERE "id" = ${userId}
FOR UPDATE
`
if (!userRows || userRows.length === 0) {
Ohne Zeilenebenen-Sperren könnten zwei gleichzeitige KI-Anfragen beide ein Guthaben von 10 Credits lesen, beide versuchen, 8 abzubuchen, und beide erfolgreich sein — was den Nutzer mit einem negativen Guthaben zurücklässt.
SELECT FOR UPDATE stellt sicher, dass immer nur eine Transaktion das Guthaben lesen und ändern kann.Funktion deductCredits()
Die Funktion
deductCredits() ist die primäre API zum Verbrauchen von Credits:typescript
import { deductCredits } from '@/lib/credits/credit-manager'
const result = await deductCredits({
userId: 'user_database_id',
operation: 'chat_message', // Typsicherer Operationsname
quantity: 1, // Anzahl der Operationen (Standard: 1)
metadata: { model: 'gpt-4' } // Optionale Metadaten für das Ledger
})
if (result.success) {
// Mit der KI-Operation fortfahren
console.log(`Verbleibendes Guthaben: ${result.balance}`)
} else {
// Unzureichende Credits behandeln
console.log(result.message) // "Insufficient credits. Required: 1, Available: 0"
}
Parameter:
| Parameter | Typ | Erforderlich | Beschreibung |
|---|---|---|---|
userId | string | Ja | Datenbank-Nutzer-ID |
operation | CreditOperation | Ja | Einer von 21 Operationstypen |
quantity | number | Nein | Batch-Multiplikator (Standard: 1) |
metadata | JsonValue | Nein | Zusätzliche Daten für das Audit-Log |
Rückgabetyp:
| Feld | Typ | Beschreibung |
|---|---|---|
success | boolean | Ob die Abbuchung erfolgreich war |
balance | number | Credit-Guthaben nach der Operation |
message | string? | Fehlermeldung, falls die Abbuchung fehlgeschlagen ist |
Unzureichende Credits
Wenn ein Nutzer nicht genügend Credits hat, gibt
deductCredits() { success: false } zurück, ohne das Guthaben zu ändern. Der aufrufende Code sollte dies angemessen behandeln:typescript
const result = await deductCredits({ userId, operation: 'image_gen' })
if (!result.success) {
return NextResponse.json(
{
error: 'Insufficient credits',
required: getCreditCost('image_gen'),
available: result.balance,
upgradeUrl: '/dashboard/billing',
},
{ status: 402 }
)
}
Kit speichert
creditBalance und creditsPerMonth als Decimal-Felder in Prisma. Dies vermeidet Gleitkomma-Präzisionsprobleme, die bei JavaScript-number-Typen auftreten würden. Der Credit-Manager konvertiert an der Grenze zwischen Decimal und number.Credit-Kosten pro Operation
Jede KI-Operation hat in
credit-costs.ts definierte Credit-Kosten. Die Kosten basieren auf dem geschätzten Token-Verbrauch und der Rechenintensität:| Operation | Credits | Kategorie | Beschreibung |
|---|---|---|---|
embedding_single | 5 | Embeddings | Einzelnes Text-Embedding |
vector_search | 5 | Embeddings | Semantische Suchanfrage |
faq_simple | 5 | FAQ | Einfache RAG-Suche |
embedding_batch | 10 | Embeddings | Batch-Embeddings |
pdf_parse | 15 | Dokument | PDF-Textextraktion |
faq_complex | 15 | FAQ | Mehrstufige Argumentation |
chat_message | 15 | Chat | Standard-Chat-Nachricht |
tts | 20 | Audio | Text-to-Speech |
speech_to_text | 20 | Audio | Spracheingabe-Transkription |
chat_streaming | 20 | Chat | Streaming-Chat-Nachricht |
content_generation | 25 | Content | Vorlagenbasierte Textgenerierung |
transcription | 30 | Audio | Sprache-zu-Text |
ocr | 30 | Dokument | Bild-Textextraktion |
image_analysis | 30 | Erweiterte KI | Bildanalyse |
chat_with_tools | 30 | Chat | Chat mit Function Calling |
pdf_analysis | 40 | Dokument | PDF-Analyse |
code_analysis | 40 | Erweiterte KI | Code-Review und -Analyse |
image_edit | 50 | Erweiterte KI | Bildbearbeitung/-manipulation |
code_gen | 50 | Erweiterte KI | Code-Generierung aus Spezifikationen |
document_summary | 65 | Dokument | Dokumentenzusammenfassung |
image_gen | 80 | Erweiterte KI | Text-zu-Bild-Generierung |
Um benutzerdefinierte Operationen hinzuzufügen, erweitere das
CREDIT_COSTS-Objekt in apps/boilerplate/src/lib/credits/credit-costs.ts. Der Typ CreditOperation wird automatisch aus den Objektschlüsseln generiert, sodass TypeScript die neue Operation in der gesamten Codebasis erzwingt.Monatliche Credit-Resets
Credits werden auf die Stufen-Zuteilung zurückgesetzt, wenn die Abrechnungszahlung des Nutzers erfolgreich war. Kit unterstützt zwei Reset-Auslöser:
Primär: Webhook-Auslöser
Das Webhook-Event
subscription_payment_success löst einen Credit-Reset aus:- Lemon Squeezy verarbeitet die monatliche Zahlung
- Webhook feuert mit dem Event
subscription_payment_success - Handler ruft
resetMonthlyCredits(userId, 'webhook')auf - Guthaben wird auf
creditsPerMonthgesetzt,creditsResetAtwird aufNOWgesetzt - Eine
monthly_reset-Transaktion wird im Credit-Ledger protokolliert
Backup: Auto-Reset
Falls der Webhook fehlschlägt (Netzwerkprobleme, Server-Ausfall), prüft ein Auto-Reset-Mechanismus, ob Credits einen Reset überfällig haben. Wenn
creditsResetAt mehr als 30 Tage in der Vergangenheit liegt und der Nutzer ein aktives Abonnement hat, werden Credits automatisch bei der nächsten Credit-Prüfung zurückgesetzt.Der
creditsResetAt-Zeitstempel wird zum Zeitpunkt des Resets auf NOW gesetzt, nicht auf ein festes Kalenderdatum. Dies richtet den Credit-Zyklus exakt am Abrechnungszyklus aus, selbst wenn Zahlungen verzögert oder wiederholt werden.Bonus-Credits
Nutzer können zusätzliche Credits über ihre monatliche Zuteilung hinaus kaufen.
Top-ups kaufen
Jede Stufe bietet bis zu 2 Bonus-Credit-Pakete, konfigurierbar über Umgebungsvariablen:
bash
# Basic-Stufe-Pakete
NEXT_PUBLIC_BONUS_BASIC_PACKAGE1_CREDITS="500"
NEXT_PUBLIC_BONUS_BASIC_PACKAGE1_PRICE="4.99"
NEXT_PUBLIC_BONUS_BASIC_PACKAGE1_VARIANT_ID="334455"
NEXT_PUBLIC_BONUS_BASIC_PACKAGE2_CREDITS="1200"
NEXT_PUBLIC_BONUS_BASIC_PACKAGE2_PRICE="9.99"
NEXT_PUBLIC_BONUS_BASIC_PACKAGE2_VARIANT_ID="334456"
Bonus-Credit-Käufe werden über das Webhook-Event
order_created verarbeitet (Einmalkäufe, keine Abonnements). Den vollständigen Webhook-Fluss findest du unter Webhooks.Monatliche Kauflimits
Jede Stufe hat ein konfigurierbares monatliches Kauflimit, das begrenzt, wie viele Bonus-Credits ein Nutzer pro Kalendermonat kaufen kann:
| Stufe | Umgebungsvariable | Standard |
|---|---|---|
| Free | — | 0 (kann nicht kaufen) |
| Basic | NEXT_PUBLIC_BONUS_BASIC_MAX_PER_MONTH | 3.000 |
| Pro | NEXT_PUBLIC_BONUS_PRO_MAX_PER_MONTH | 10.000 |
| Enterprise | NEXT_PUBLIC_BONUS_ENTERPRISE_MAX_PER_MONTH | 40.000 |
Das Limit wird sowohl in der UI (der Kaufbutton wird deaktiviert, wenn das Limit erreicht ist) als auch serverseitig erzwungen (der Endpunkt
/api/credits/checkout gibt 403 zurück). Kaufzählungen werden zu Beginn jedes Kalendermonats zurückgesetzt.Kauffluss
Der Bonus-Credit-Kauffluss umfasst die Dashboard-UI, zwei API-Endpunkte und einen Webhook:
/dashboard/credits
|
v
BonusPackages-Komponente (lädt via GET /api/credits/bonus-packages)
|--- Zeigt stufenspezifische Pakete mit Preisen an
|--- Zeigt "Bestes Preis-Leistungs-Verhältnis"-Badge auf dem günstigsten Paket pro Credit
|--- Deaktiviert Käufe, wenn monatliches Limit erreicht
|
v
Nutzer klickt "Kaufen" → POST /api/credits/checkout
|--- Validiert Variant-ID gegen Stufen-Pakete
|--- Prüft monatliches Kauflimit
|--- Erstellt Lemon Squeezy Checkout (Einmalkauf)
|
v
Lemon Squeezy Checkout-Seite → Nutzer schließt Zahlung ab
|
v
order_created-Webhook → purchaseBonusCredits()
|--- Idempotenzprüfung (lemonSqueezyId-Unique-Constraint)
|--- Erhöht user.bonusCredits atomar
|--- Erstellt BonusCreditPurchase mit Ablaufdatum
|--- Protokolliert "purchase"-Transaktion im Credit-Ledger
|--- Sendet Bestätigungs-E-Mail (nicht-blockierend)
|
v
Weiterleitung zu /dashboard/credits?purchase=success
|--- Erfolgsbanner mit automatischer Ausblendung nach 30 Sekunden
|--- Pollt Credit-Guthaben für 30 s, um neuen Kauf zu spiegeln
Bonus-Credit-Pakete verwenden Lemon Squeezy Einmalkäufe (
order_created-Event), keine Abonnement-Abrechnung. Jeder Kauf ist eine eigenständige Transaktion ohne wiederkehrende Gebühren. Beim Einrichten von Produkten in Lemon Squeezy erstelle sie als Einzahlungsprodukte — Konfigurationsschritte findest du unter Lemon Squeezy Setup.Ablauf
Bonus-Credits laufen nach einem konfigurierbaren Zeitraum ab (Standard: 12 Monate). Die Umgebungsvariable
NEXT_PUBLIC_BONUS_CREDITS_EXPIRY_MONTHS steuert dies. Abgelaufene Bonus-Credits werden nicht automatisch abgezogen — der Ablauf wird zum Verbrauchszeitpunkt geprüft.Verbrauchspriorität
Das Credit-System unterstützt zwei Verbrauchsstrategien, gesteuert durch
NEXT_PUBLIC_BONUS_CREDITS_CONSUME_FIRST:Bonus zuerst (
CONSUME_FIRST=true, Standard):Nutzer hat: 50 Bonus-Credits + 100 Abonnement-Credits
75 Credits abbuchen:
1. Nur Bonus (wenn Bonus >= Kosten)
→ 75 Bonus-Credits verwenden
2. Gemischt (wenn Bonus < Kosten, aber Gesamt >= Kosten)
→ 50 Bonus + 25 Abonnement-Credits verwenden
3. Unzureichend (wenn Gesamt < Kosten)
→ Abbuchung schlägt fehl, keine Credits verbraucht
Abonnement zuerst (
CONSUME_FIRST=false):Nutzer hat: 50 Bonus-Credits + 100 Abonnement-Credits
75 Credits abbuchen:
1. Nur Abonnement (wenn Abonnement >= Kosten)
→ 75 Abonnement-Credits verwenden
2. Bonus als Fallback (wenn Abonnement < Kosten, aber Gesamt >= Kosten)
→ 100 Abonnement-Credits verwenden + Restbetrag von Bonus nehmen
3. Unzureichend (wenn Gesamt < Kosten)
→ Abbuchung schlägt fehl, keine Credits verbraucht
Bonus zuerst (Standard) schützt die monatliche Abonnement-Zuteilung des Nutzers und stellt sicher, dass Bonus-Credits vor ihrem Ablauf verbraucht werden. Abonnement zuerst bewahrt gekaufte Bonus-Credits als Reserve, während die erneuerbare monatliche Zuteilung verbraucht wird.
Die Funktion
consumeCreditsWithBonus() im Credit-Manager verarbeitet beide Strategien atomar mit demselben SELECT FOR UPDATE-Sperrmuster wie reguläre Abbuchungen. Intern delegiert sie je nach consumeFirst-Konfiguration an consumeBonusFirst() oder consumeSubscriptionFirst().Nutzer-Toggle
Wenn
NEXT_PUBLIC_BONUS_CREDITS_USER_TOGGLE_ENABLED=true, können einzelne Nutzer steuern, ob ihre Bonus-Credits automatisch verbraucht werden. Dies fügt dem User-Modell ein Boolean-Feld bonusCreditsAutoUse hinzu (Standard: false).So funktioniert es:
- Das Credit-System prüft die
bonusCreditsAutoUse-Präferenz des Nutzers, bevor Bonus-Credits verbraucht werden - Wenn der Nutzer nicht eingewilligt hat, werden Bonus-Credits übersprungen, auch wenn sie verfügbar sind
- Ein Soft-Prompt-Dialog erscheint, wenn die Abonnement-Credits des Nutzers niedrig sind, mit dem Vorschlag, den Bonus-Credit-Verbrauch zu aktivieren
- Nutzer verwalten ihre Präferenz über den Endpunkt
/api/credits/preferences(GET und PATCH)
Der Hook
useBonusPreference bietet clientseitiges State-Management für den Toggle, gestützt auf TanStack Query mit optimistischen Updates.Der Nutzer-Toggle ist standardmäßig deaktiviert. Setze
NEXT_PUBLIC_BONUS_CREDITS_USER_TOGGLE_ENABLED=true in deiner Umgebung, um ihn zu aktivieren. Wenn deaktiviert, gilt die systemweite CONSUME_FIRST-Einstellung für alle Nutzer.Credit-Ledger
Jede Credit-Operation erstellt einen unveränderlichen
CreditTransaction-Datensatz in der Datenbank. Dies bietet einen vollständigen Prüfpfad:| Feld | Typ | Beschreibung |
|---|---|---|
id | String | Eindeutige Transaktions-ID |
userId | String | Nutzer, dem diese Transaktion gehört |
amount | Decimal(10,2) | Credit-Änderung (negativ für Abbuchungen, positiv für Rückerstattungen/Resets) |
balanceAfter | Decimal(10,2) | Credit-Guthaben nach dieser Transaktion |
type | String | usage, refund, monthly_reset, purchase oder adjustment |
operation | String? | Operationstyp (z. B. chat_message, image_gen) |
metadata | Json? | Zusätzlicher Kontext (Modell, Menge, Auslöserquelle) |
createdAt | DateTime | Zeitpunkt der Transaktion |
Verwende immer
deductCredits(), refundCredits() oder resetMonthlyCredits(), um das Credit-Guthaben eines Nutzers zu ändern. Diese Funktionen gewährleisten die atomare Transaktionsgarantie und erstellen Audit-Log-Einträge. Direkte SQL-Updates auf User.creditBalance erzeugen eine Inkonsistenz zwischen dem Guthaben und dem Ledger.Das Ledger abfragen
Verwende das Credit-Ledger für Analysen, Streitbeilegung und Nutzungsberichte:
typescript
// Alle Transaktionen für einen Nutzer abrufen
const transactions = await prisma.creditTransaction.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: 50,
})
// Nutzungszusammenfassung nach Operationstyp
const usageSummary = await prisma.creditTransaction.groupBy({
by: ['operation'],
where: { userId, type: 'usage' },
_sum: { amount: true },
_count: true,
})
Stufenwechsel-Anpassungen
Wenn ein Nutzer die Stufe wechselt, werden Credits an die neue Zuteilung angepasst:
Upgrades
Bei einem Upgrade erhält der Nutzer die volle Credit-Zuteilung der neuen Stufe. Der
updateUserCredits()-Helper (aufgerufen vom subscription_updated-Webhook-Handler) setzt sowohl creditsPerMonth als auch creditBalance auf die Werte der neuen Stufe.Downgrades
Bei einem Downgrade wird die Credit-Zuteilung beim nächsten monatlichen Reset reduziert. Wenn der Nutzer aktuell mehr Credits hat, als die neue Stufe erlaubt, behält er den Überschuss bis zum nächsten Reset-Zyklus. Beim Reset wird das Guthaben auf die Zuteilung der niedrigeren Stufe gesetzt.
Fallback auf Free-Stufe
Wenn ein Abonnement abläuft oder nach der Gnadenfrist gekündigt wird, wechselt der Nutzer zur Free-Stufe mit 500 Credits pro Monat. Alle verbleibenden bezahlten Credits bleiben bis zum nächsten automatischen Reset erhalten.