feat(abrechnung): Credits-Uebertrag in den Folgemonat entfernt

Produktentscheidung 07/2026: ungenutzte Credits verfallen zum
Periodenende, es gilt der harte Monatsdeckel wie verkauft. Der
Uebertrag (credits_rollover/credits_carried) fliegt komplett raus:
roll_credit_period setzt nur noch Verbrauch + Warnflag zurueck,
check_license rechnet verfuegbar = credits_total, die Migration legt
die beiden Spalten gar nicht erst an (Live hat sie nie bekommen, die
Promote-Warteschlange enthielt sie noch). ABRECHNUNG.md angepasst.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dieser Commit ist enthalten in:
claude-dev
2026-07-25 21:45:15 +00:00
Ursprung 5450fd25ae
Commit f8499c4e40
3 geänderte Dateien mit 15 neuen und 34 gelöschten Zeilen

Datei anzeigen

@@ -61,11 +61,9 @@ Der Wechsel passiert träge bei der nächsten Lizenzprüfung, nicht über einen
Zeitplan. Ein verpasster Monatswechsel wird dadurch beim nächsten Zugriff
nachgeholt, und ohne Nutzung wird ohnehin nichts verbraucht.
`credits_rollover` überträgt ungenutzte Credits in den Folgemonat, gedeckelt
auf ein Monatskontingent. Der Übertrag steht in `credits_carried` und zählt beim
Hard-Stop mit. Das ist empfehlenswert, weil OSINT-Nutzung ereignisgetrieben ist.
Bricht eine Krise aus, braucht der Kunde in einer Woche mehr als sonst im
Quartal, und genau dann darf das System nicht dichtmachen.
Ungenutzte Credits verfallen zum Periodenende. Ein Übertrag in den Folgemonat
war kurz vorgesehen und wurde als Produktentscheidung 07/2026 wieder entfernt,
es gilt der harte Monatsdeckel wie verkauft.
Eine Bestandslizenz ohne Periodenmarke bekommt beim ersten Zugriff die aktuelle
Periode eingetragen, **ohne** den Verbrauch zurückzusetzen. Sonst würden ihr
@@ -93,7 +91,6 @@ nächste Schritt.
| `credits_total` | Kontingent je Periode | 10000 |
| `cost_per_credit` | nur für `BILLING_MODE=actual` und die interne Rechnung | 0.20 |
| `credits_period` | `monthly` oder `total` | monthly |
| `credits_rollover` | Übertrag erlauben | 1 |
| `budget_warning_percent` | Warnschwelle in Prozent | 80 |
| `unlimited_budget` | Kontingent aushebeln | 0 |

Datei anzeigen

@@ -877,10 +877,9 @@ async def init_db():
# Beginn der laufenden Periode als YYYY-MM. Leer = beim naechsten
# Zugriff auf den aktuellen Monat gesetzt, ohne Verbrauch zu loeschen.
await db.execute("ALTER TABLE licenses ADD COLUMN credits_period_start TEXT")
# Ungenutzte Credits in die Folgeperiode uebertragen. Aus = harter
# Monatsdeckel wie verkauft, An = faengt Krisenspitzen ab.
await db.execute("ALTER TABLE licenses ADD COLUMN credits_rollover INTEGER DEFAULT 0")
await db.execute("ALTER TABLE licenses ADD COLUMN credits_carried REAL DEFAULT 0")
# Ungenutzte Credits verfallen zum Periodenende. Ein Uebertrag in den
# Folgemonat war kurz vorgesehen und wurde als Produktentscheidung
# 07/2026 wieder entfernt (harter Monatsdeckel wie verkauft).
# Verhindert, dass die Warnschwelle bei jeder Buchung erneut meldet.
await db.execute("ALTER TABLE licenses ADD COLUMN budget_warning_sent INTEGER DEFAULT 0")
await db.commit()

Datei anzeigen

@@ -41,8 +41,8 @@ async def roll_credit_period(db: aiosqlite.Connection, lic: dict) -> dict:
Das ist robuster, weil ein verpasster Monatswechsel beim naechsten Zugriff
ohnehin nachgeholt wird und ohne Nutzung auch nichts verbraucht wird.
Bei aktivem Uebertrag wandert ungenutztes Guthaben in die Folgeperiode,
gedeckelt auf ein Monatskontingent, damit es nicht unbegrenzt anwaechst.
Ungenutzte Credits verfallen zum Periodenende (kein Uebertrag,
Produktentscheidung 07/2026).
Returns:
Das ggf. aktualisierte Lizenz-dict (in-place ergaenzt).
@@ -70,33 +70,21 @@ async def roll_credit_period(db: aiosqlite.Connection, lic: dict) -> dict:
if started == period:
return lic
total = lic.get("credits_total") or 0
carried_old = lic.get("credits_carried") or 0
used = lic.get("credits_used") or 0
if lic.get("credits_rollover"):
carried_new = max(0.0, (total + carried_old) - used)
carried_new = min(carried_new, float(total)) # hoechstens ein Monat
else:
carried_new = 0.0
await db.execute(
"""UPDATE licenses
SET credits_used = 0, credits_carried = ?, credits_period_start = ?,
SET credits_used = 0, credits_period_start = ?,
budget_warning_sent = 0
WHERE id = ?""",
(round(carried_new, 2), period, lic["id"]),
(period, lic["id"]),
)
await db.commit()
lic["credits_used"] = 0
lic["credits_carried"] = carried_new
lic["credits_period_start"] = period
lic["budget_warning_sent"] = 0
logger.info(
f"Lizenz {lic['id']}: neue Periode {period}, Verbrauch zurueckgesetzt "
f"(Uebertrag {round(carried_new, 2)} Einheiten)"
f"Lizenz {lic['id']}: neue Periode {period}, Verbrauch zurueckgesetzt"
)
return lic
@@ -185,10 +173,10 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
unlimited_budget = bool(lic_dict.get("unlimited_budget"))
credits_total = lic_dict.get("credits_total")
credits_used = lic_dict.get("credits_used") or 0
credits_carried = lic_dict.get("credits_carried") or 0
credits_period = lic_dict.get("credits_period") or "monthly"
# Verfuegbar ist das Kontingent plus ein etwaiger Uebertrag aus dem Vormonat.
credits_available = (credits_total or 0) + credits_carried
# Verfuegbar ist genau das Kontingent. Ungenutzte Credits verfallen zum
# Periodenende (kein Uebertrag, Produktentscheidung 07/2026).
credits_available = credits_total or 0
# STAGING_MODE: kein Token-Budget-Hard-Stop, immer unlimited
if _staging_mode():
@@ -219,7 +207,6 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
pass
# Budget-Check (Hard-Stop bei aufgebrauchtem Guthaben, ausser unlimited).
# Geprueft wird gegen das verfuegbare Guthaben, also inklusive Uebertrag.
budget_exceeded = False
if not unlimited_budget and credits_available > 0:
if credits_used >= credits_available:
@@ -245,7 +232,6 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
"unlimited_budget": False,
"credits_total": credits_total,
"credits_used": credits_used,
"credits_carried": credits_carried,
"credits_available": credits_available,
"credits_period": credits_period,
}
@@ -262,7 +248,6 @@ async def check_license(db: aiosqlite.Connection, organization_id: int) -> dict:
"unlimited_budget": unlimited_budget,
"credits_total": credits_total,
"credits_used": credits_used,
"credits_carried": credits_carried,
"credits_available": credits_available,
"credits_period": credits_period,
}
@@ -437,7 +422,7 @@ async def _charge_credits(
)
used_new = (lic.get("credits_used") or 0) + credits_consumed
available = (lic.get("credits_total") or 0) + (lic.get("credits_carried") or 0)
available = lic.get("credits_total") or 0
logger.info(
f"charge_usage_to_tenant[{key}] Tenant {tenant_id}: "