Credit-System

Credit-basiertes Pricing mit atomaren Abbuchungen, monatlichen Resets, Bonus-Käufen und Kosten pro Operation

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:
StufeStandard-CreditsUmgebungsvariableFallback
Free500CREDIT_SYSTEM_FREE_CREDITS500
Basic1.500CREDIT_SYSTEM_BASIC_CREDITS1.500
Pro5.000CREDIT_SYSTEM_PRO_CREDITS5.000
Enterprise15.000CREDIT_SYSTEM_ENTERPRISE_CREDITS15.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) {

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:
ParameterTypErforderlichBeschreibung
userIdstringJaDatenbank-Nutzer-ID
operationCreditOperationJaEiner von 21 Operationstypen
quantitynumberNeinBatch-Multiplikator (Standard: 1)
metadataJsonValueNeinZusätzliche Daten für das Audit-Log
Rückgabetyp:
FeldTypBeschreibung
successbooleanOb die Abbuchung erfolgreich war
balancenumberCredit-Guthaben nach der Operation
messagestring?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 }
  )
}

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:
OperationCreditsKategorieBeschreibung
embedding_single5EmbeddingsEinzelnes Text-Embedding
vector_search5EmbeddingsSemantische Suchanfrage
faq_simple5FAQEinfache RAG-Suche
embedding_batch10EmbeddingsBatch-Embeddings
pdf_parse15DokumentPDF-Textextraktion
faq_complex15FAQMehrstufige Argumentation
chat_message15ChatStandard-Chat-Nachricht
tts20AudioText-to-Speech
speech_to_text20AudioSpracheingabe-Transkription
chat_streaming20ChatStreaming-Chat-Nachricht
content_generation25ContentVorlagenbasierte Textgenerierung
transcription30AudioSprache-zu-Text
ocr30DokumentBild-Textextraktion
image_analysis30Erweiterte KIBildanalyse
chat_with_tools30ChatChat mit Function Calling
pdf_analysis40DokumentPDF-Analyse
code_analysis40Erweiterte KICode-Review und -Analyse
image_edit50Erweiterte KIBildbearbeitung/-manipulation
code_gen50Erweiterte KICode-Generierung aus Spezifikationen
document_summary65DokumentDokumentenzusammenfassung
image_gen80Erweiterte KIText-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:
  1. Lemon Squeezy verarbeitet die monatliche Zahlung
  2. Webhook feuert mit dem Event subscription_payment_success
  3. Handler ruft resetMonthlyCredits(userId, 'webhook') auf
  4. Guthaben wird auf creditsPerMonth gesetzt, creditsResetAt wird auf NOW gesetzt
  5. 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.

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:
StufeUmgebungsvariableStandard
Free0 (kann nicht kaufen)
BasicNEXT_PUBLIC_BONUS_BASIC_MAX_PER_MONTH3.000
ProNEXT_PUBLIC_BONUS_PRO_MAX_PER_MONTH10.000
EnterpriseNEXT_PUBLIC_BONUS_ENTERPRISE_MAX_PER_MONTH40.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

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
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:
  1. Das Credit-System prüft die bonusCreditsAutoUse-Präferenz des Nutzers, bevor Bonus-Credits verbraucht werden
  2. Wenn der Nutzer nicht eingewilligt hat, werden Bonus-Credits übersprungen, auch wenn sie verfügbar sind
  3. Ein Soft-Prompt-Dialog erscheint, wenn die Abonnement-Credits des Nutzers niedrig sind, mit dem Vorschlag, den Bonus-Credit-Verbrauch zu aktivieren
  4. 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.

Credit-Ledger

Jede Credit-Operation erstellt einen unveränderlichen CreditTransaction-Datensatz in der Datenbank. Dies bietet einen vollständigen Prüfpfad:
FeldTypBeschreibung
idStringEindeutige Transaktions-ID
userIdStringNutzer, dem diese Transaktion gehört
amountDecimal(10,2)Credit-Änderung (negativ für Abbuchungen, positiv für Rückerstattungen/Resets)
balanceAfterDecimal(10,2)Credit-Guthaben nach dieser Transaktion
typeStringusage, refund, monthly_reset, purchase oder adjustment
operationString?Operationstyp (z. B. chat_message, image_gen)
metadataJson?Zusätzlicher Kontext (Modell, Menge, Auslöserquelle)
createdAtDateTimeZeitpunkt der Transaktion

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.