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:
@@ -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 |
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}: "
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren