changes to the Bookingflow

This commit is contained in:
2026-10-02 16:46:45 +02:00
parent 1e7bc1e2a9
commit e24a74f7a2
5 changed files with 275 additions and 38 deletions
+7
View File
@@ -356,6 +356,13 @@ watcher_loop() {
echo "[ERROR] Testversion konnte nicht deaktiviert werden: $SLUG" >&2
fi
;;
"module")
if [ -n "$MODULES" ] && (cd "$TARGET_SYSTEM_DIR" && "$TARGET_SCRIPT" module "$SLUG" $MODULES); then
echo "[SUCCESS] Module für $SLUG aktualisiert"
else
echo "[ERROR] Module für $SLUG konnten nicht aktualisiert werden" >&2
fi
;;
"trial")
echo "[ERROR] Veralteter trial-Trigger für $SLUG: Testversionen müssen über add erstellt werden." >&2
;;
+228 -35
View File
@@ -161,6 +161,7 @@ def _env_int(name: str, default: int) -> int:
TRIAL_REMINDER_DAYS = max(_env_int("TRIAL_REMINDER_DAYS", 3), 1)
TRIAL_DELETE_GRACE_DAYS = max(_env_int("TRIAL_DELETE_GRACE_DAYS", 30), 1)
TRIAL_MAINTENANCE_INTERVAL = max(_env_int("TRIAL_MAINTENANCE_INTERVAL", 60), 10)
PAYMENT_GRACE_DAYS = 14
MONGO_MAX_POOL_SIZE = max(_env_int("MONGO_MAX_POOL_SIZE", 12), 1)
@@ -482,9 +483,7 @@ def _list_school_instances() -> list:
if client:
client.close()
normalized = []
for row in rows:
normalized.append(
normalized = [
{
"id": str(row.get("_id") or ""),
"school_name": _sanitize_text(row.get("school_name") or "", 120),
@@ -499,8 +498,11 @@ def _list_school_instances() -> list:
"nginx_status": _sanitize_text(row.get("nginx_status") or "unbekannt", 80),
"last_message": _sanitize_text(row.get("last_message") or "", 500),
"updated_at": row.get("updated_at") or row.get("created_at") or "",
"billing_state": _sanitize_text(row.get("billing_state") or "active", 30),
"payment_due_at": row.get("payment_due_at") or "",
}
)
for row in rows
]
return normalized
@@ -2235,6 +2237,7 @@ def background_booking_provisioning(
software_name="Invario Inventarsystem",
is_trial=False,
trial_days=14,
invoice_id=None,
):
"""
Hintergrund-Worker, der die Tenant-Bereitstellung über das Host-Watcher-Triggersystem auslöst.
@@ -2314,6 +2317,10 @@ def background_booking_provisioning(
"admin_password": admin_password,
"created_at": _utc_now_iso(),
"is_trial": is_trial,
"package": ",".join(package_list),
"invoice_id": invoice_id,
"billing_state": "active" if is_trial else "active",
"payment_status": "paid" if is_trial else "pending",
}
if is_trial:
@@ -2324,6 +2331,8 @@ def background_booking_provisioning(
instance_doc["trial_reminder_email_sent"] = False
instance_doc["trial_expired_email_sent"] = False
instance_doc["trial_deleted_email_sent"] = False
else:
instance_doc["payment_due_at"] = (date.today() + timedelta(days=PAYMENT_GRACE_DAYS)).isoformat()
inst_client, inst_col = _get_collection("instances")
try:
@@ -2479,6 +2488,88 @@ def _queue_trial_action(action: str, instance: dict) -> None:
json.dump({"action": action, "slug": slug}, handle)
def _queue_instance_module_action(instance: dict, module_config: str, action: str = "module") -> None:
slug = _sanitize_text(instance.get("subdomain") or "", 63)
if not slug:
return
os.makedirs(TRIGGER_DIR, exist_ok=True)
trigger_path = os.path.join(TRIGGER_DIR, f"billing_{action}_{slug}_{int(time.time())}.json")
with open(trigger_path, "w", encoding="utf-8") as handle:
json.dump({"action": action, "slug": slug, "module_config": module_config}, handle)
def _instance_is_paid(instance: dict, invoice: dict | None = None) -> bool:
if instance.get("manual_payment_override") is True:
return True
if invoice is not None:
return (invoice.get("status") or "").strip().lower() == "bezahlt"
return bool(instance.get("payment_status") == "paid")
def _set_instance_billing_state(instance: dict, state: str, reason: str) -> None:
client, col = _get_collection("instances")
try:
col.update_one(
{"_id": instance.get("_id")},
{"$set": {
"billing_state": state,
"status": "suspended" if state == "suspended" else ("active" if state == "paid" else instance.get("status", "ready")),
"last_message": reason,
"updated_at": _utc_now_iso(),
}},
)
finally:
client.close()
if state == "suspended":
_queue_instance_module_action(instance, " ".join(f"{module}=off" for module in ALL_MODULE_KEYS), "module")
elif state == "paid":
package_list = [item.strip() for item in (instance.get("package") or "").split(",") if item.strip()]
if package_list:
_queue_instance_module_action(instance, build_module_config_string(package_list), "module")
def _run_billing_maintenance_once() -> None:
now = datetime.now(timezone.utc)
client, col = _get_collection("instances")
try:
instances = list(col.find({"is_trial": {"$ne": True}, "invoice_id": {"$exists": True}}))
finally:
client.close()
for instance in instances:
invoice_client, invoice_col = _get_collection("invoices")
try:
invoice = invoice_col.find_one({"_id": ObjectId(str(instance.get("invoice_id")))})
except Exception:
invoice = None
finally:
invoice_client.close()
if not invoice or _instance_is_paid(instance, invoice):
continue
due_at = _trial_datetime(invoice.get("due_date"))
if due_at and now >= due_at and instance.get("billing_state") != "suspended":
_set_instance_billing_state(instance, "suspended", "Instanz wegen nicht bezahlter Rechnung gesperrt.")
def _sync_invoice_status(invoice_id: str, username: str, status: str) -> None:
client, col = _get_collection("instances")
try:
instances = list(col.find({"$or": [
{"invoice_id": invoice_id},
{"owner_username": username, "is_trial": {"$ne": True}},
]}))
for instance in instances:
col.update_one(
{"_id": instance.get("_id")},
{"$set": {"invoice_id": invoice_id, "payment_status": "paid" if status == "Bezahlt" else "pending", "updated_at": _utc_now_iso()}},
)
if status == "Bezahlt":
instance["invoice_id"] = invoice_id
_set_instance_billing_state(instance, "paid", "Rechnung durch den Admin als bezahlt bestätigt.")
finally:
client.close()
def _trial_recipient(instance: dict) -> str:
owner = _find_user(instance.get("owner_username") or "")
return _sanitize_text((owner or {}).get("email") or "", 254)
@@ -2578,6 +2669,7 @@ def _trial_maintenance_loop(app_instance) -> None:
try:
with app_instance.app_context():
_run_trial_maintenance_once()
_run_billing_maintenance_once()
except Exception:
traceback.print_exc()
time.sleep(TRIAL_MAINTENANCE_INTERVAL)
@@ -2619,6 +2711,16 @@ def booking_payment():
is_trial = booking_flow in {"trial", "testversion"} or requested_trial or package_requests_trial
trial_days = int(request.values.get("trial_days") or 14)
active_trial_client, active_trial_col = _get_collection("instances")
try:
active_trial = active_trial_col.find_one({
"owner_username": session.get("username"),
"is_trial": True,
"trial_state": {"$in": ["active", "in_progress", "expired"]},
})
finally:
active_trial_client.close()
if is_trial and _user_has_used_trial(session.get("username") or ""):
error_message = "Diese Testversion wurde für Ihr Konto bereits aktiviert. Bitte wenden Sie sich zum Upgrade an unser Team."
if request.is_json or "application/json" in request.headers.get("Accept", ""):
@@ -2690,6 +2792,11 @@ def booking_payment():
"username": session.get("username"),
"display_name": session.get("display_name") or session.get("username"),
"form_data": form_data,
"active_trial": {
"school_name": active_trial.get("school_name") if active_trial else "",
"domain": active_trial.get("domain") if active_trial else "",
"expires_at": active_trial.get("expires_at") if active_trial else "",
} if active_trial and not is_trial else None,
}
if request.method == "GET":
@@ -2769,6 +2876,16 @@ def booking_payment():
effective_subdomain = base
active_trial_client, active_trial_col = _get_collection("instances")
try:
active_trial = active_trial_col.find_one({
"owner_username": session.get("username"),
"is_trial": True,
"trial_state": {"$in": ["active", "in_progress", "expired"]},
}) if not is_trial else None
finally:
active_trial_client.close()
inst_check_client, inst_col_check = _get_collection("instances")
try:
existing_docs = list(inst_col_check.find({}, {"subdomain": 1}))
@@ -2779,7 +2896,7 @@ def booking_payment():
finally:
inst_check_client.close()
if effective_subdomain in existing_subdomains:
if effective_subdomain in existing_subdomains and not active_trial:
err_msg = "Die gewünschte Subdomain existiert bereits. Bitte wähle eine andere Subdomain."
if is_json_request:
return jsonify({"success": False, "error": err_msg}), 400
@@ -2788,6 +2905,10 @@ def booking_payment():
subdomain = effective_subdomain[:63]
if active_trial:
subdomain = active_trial.get("subdomain") or subdomain
effective_subdomain = subdomain
provision_payload = {
"user_id": session.get("user_id"),
"username": session.get("username"),
@@ -2814,6 +2935,69 @@ def booking_payment():
finally:
req_client.close()
invoice_id = None
if not is_trial:
due_date = (date.today() + timedelta(days=PAYMENT_GRACE_DAYS)).isoformat()
invoice_client, invoice_col = _get_collection("invoices")
try:
invoice_id = str(invoice_col.insert_one({
"username": session.get("username"),
"invoice_number": booking_number,
"period": date.today().strftime("%m/%Y"),
"amount_eur": total_price,
"status": "Offen",
"due_date": due_date,
"created_at": _utc_now_iso(),
"source": "booking_payment",
"booking_number": booking_number,
"booking_package": package_raw,
"booking_package_label": selected_package,
"booking_flow": booking_flow,
"booking_data": form_data,
}).inserted_id)
req_client, req_col = _get_collection("instance_requests")
try:
req_col.update_one({"_id": ObjectId(prov_id)}, {"$set": {"invoice_id": invoice_id, "due_date": due_date}})
finally:
req_client.close()
finally:
invoice_client.close()
if active_trial:
module_config = build_module_config_string(package_list)
instance_client, instance_col = _get_collection("instances")
try:
instance_col.update_one(
{"_id": active_trial.get("_id")},
{"$set": {
"is_trial": False,
"trial_state": "upgraded",
"package": package_raw,
"invoice_id": invoice_id,
"billing_state": "active",
"payment_status": "pending",
"payment_due_at": (date.today() + timedelta(days=PAYMENT_GRACE_DAYS)).isoformat(),
"status": "ready",
"last_message": "Trial in reguläre Buchung übernommen; Zahlungsziel beträgt 14 Tage.",
"updated_at": _utc_now_iso(),
}},
)
finally:
instance_client.close()
_queue_instance_module_action(active_trial, module_config)
req_client, req_col = _get_collection("instance_requests")
try:
req_col.update_one(
{"_id": ObjectId(prov_id)},
{"$set": {"provision_status": "ready", "tenant_slug": subdomain, "instance_id": str(active_trial.get("_id")), "url": f"https://{active_trial.get('domain') or subdomain + '.invario-software.de'}"}},
)
finally:
req_client.close()
if is_json_request:
return jsonify({"success": True, "slug": subdomain, "message": "Ihre bestehende Trial-Instanz wurde für die Buchung reaktiviert."}), 200
flash(f"Buchung für {selected_package} wurde erfasst und Ihre Trial-Instanz reaktiviert.", "success")
return redirect(url_for("my_instance_management"))
app_obj = current_app._get_current_object()
user_id = session.get("user_id")
username = session.get("username")
@@ -2838,7 +3022,8 @@ def booking_payment():
),
kwargs={
"is_trial": is_trial,
"trial_days": trial_days
"trial_days": trial_days,
"invoice_id": invoice_id,
}
)
thread.start()
@@ -3206,6 +3391,9 @@ def my_instance_management():
"trial_state": _sanitize_text(instance_doc.get("trial_state") or "", 40),
"expires_at": instance_doc.get("expires_at") or "",
"upgrade_requested": bool(instance_doc.get("upgrade_requested", False)),
"billing_state": _sanitize_text(instance_doc.get("billing_state") or "active", 30),
"payment_status": _sanitize_text(instance_doc.get("payment_status") or "", 30),
"payment_due_at": instance_doc.get("payment_due_at") or "",
}
else:
# Wenn noch keine Instanz da ist, prüfen wir ob eine gerade erstellt wird
@@ -3232,34 +3420,7 @@ def my_instance_management():
@app.route("/trial/upgrade", methods=["POST"])
@login_required
def request_trial_upgrade():
username = session.get("username") or ""
client, col = _get_collection("instances")
try:
instance = col.find_one({"owner_username": username, "is_trial": True})
if not instance or instance.get("trial_state") != "active":
flash("Für diese Instanz ist kein aktives Upgrade möglich.", "error")
return redirect(url_for("my_instance_management"))
col.update_one(
{"_id": instance.get("_id"), "trial_state": "active"},
{"$set": {"upgrade_requested": True, "upgrade_requested_at": _utc_now_iso(), "updated_at": _utc_now_iso()}},
)
finally:
client.close()
client, messages = _get_collection("chat_messages")
try:
messages.insert_one({
"username": username,
"sender": session.get("display_name") or username,
"sender_role": "user",
"message": "Ich möchte meine Testversion in eine reguläre Lizenz upgraden.",
"created_at": _utc_now_iso(),
"booking_flow": "trial_upgrade",
})
finally:
client.close()
flash("Ihre Upgrade-Anfrage wurde an das Team gesendet.", "success")
return redirect(url_for("user_chat"))
return redirect(url_for("preise", upgrade=1))
@app.route('/my/tutorials', methods=['GET'])
@@ -3539,7 +3700,7 @@ def admin_invoices():
flash("Bitte Benutzername und Zeitraum angeben.", "error")
return redirect(url_for("admin_invoices"))
col.insert_one(
created_invoice = col.insert_one(
{
"username": username,
"invoice_number": normalized_invoice_number,
@@ -3551,10 +3712,12 @@ def admin_invoices():
"created_at": _utc_now_iso(),
}
)
_sync_invoice_status(str(created_invoice.inserted_id), username, status)
flash("Rechnung angelegt.", "success")
# NEU HINZUGEFÜGT: Die Logik für das Update (Aktualisieren) Formular
elif action == "update" and invoice_id:
existing_invoice = col.find_one({"_id": ObjectId(invoice_id)}) or {}
invoice_number = _sanitize_text(request.form.get("invoice_number") or "", 120)
period = _sanitize_text(request.form.get("period") or "", 20)
due_date = _sanitize_text(request.form.get("due_date") or "", 20)
@@ -3585,6 +3748,7 @@ def admin_invoices():
{"_id": ObjectId(invoice_id)},
{"$set": update_data}
)
_sync_invoice_status(invoice_id, existing_invoice.get("username") or "", status)
flash("Rechnung erfolgreich aktualisiert.", "success")
elif action == "delete" and invoice_id:
@@ -3825,6 +3989,35 @@ def admin_cancel_instance(instance_id):
return redirect(url_for('admin_instances'))
@app.route('/admin/instances/billing/<instance_id>', methods=['POST'])
@login_required
def admin_instance_billing(instance_id):
if not session.get("is_admin") and session.get("role") != "admin":
flash("Zugriff verweigert.", "error")
return redirect(url_for("my_instance_management"))
action = request.form.get("action")
client, col = _get_collection("instances")
try:
instance = col.find_one({"_id": ObjectId(instance_id)})
if not instance:
flash("Instanz nicht gefunden.", "error")
return redirect(url_for("admin_instances"))
if action == "suspend":
col.update_one({"_id": instance.get("_id")}, {"$set": {"manual_payment_override": False, "manual_suspension": True}})
_set_instance_billing_state(instance, "suspended", "Instanz manuell durch den Admin gesperrt.")
flash("Instanz gesperrt.", "success")
elif action == "activate":
col.update_one({"_id": instance.get("_id")}, {"$set": {"manual_suspension": False, "manual_payment_override": True}})
_set_instance_billing_state(instance, "paid", "Instanz manuell durch den Admin freigeschaltet.")
flash("Instanz freigeschaltet.", "success")
else:
flash("Ungültige Billing-Aktion.", "error")
finally:
client.close()
return redirect(url_for("admin_instances"))
@app.route('/admin/test/drop_all_instances', methods=['POST'])
@login_required
def admin_delete_all_instances():
+26
View File
@@ -24,6 +24,7 @@
<th>Bibliothek</th>
<th>Status</th>
<th>Nginx Status</th>
<th>Zahlungsstatus</th>
<th>Letzte Meldung</th>
<th>Aktualisiert</th>
<th>Aktionen</th>
@@ -58,9 +59,23 @@
</span>
</td>
<td>{{ item.nginx_status or 'unbekannt' }}</td>
<td>
<span class="pill {{ 'ok' if item.billing_state == 'paid' or item.billing_state == 'active' else 'error' }}">
{{ item.billing_state or 'active' }}{% if item.payment_due_at %}<br><small>fällig {{ item.payment_due_at }}</small>{% endif %}
</span>
</td>
<td class="last-msg-cell">{{ item.last_message or '-' }}</td>
<td>{{ item.updated_at or '-' }}</td>
<td class="actions-cell">
<form action="{{ url_for('admin_instance_billing', instance_id=item.id) }}" method="POST" class="form-delete-single">
{% if item.billing_state == 'suspended' %}
<input type="hidden" name="action" value="activate">
<button type="submit" class="btn-activate">Freischalten</button>
{% else %}
<input type="hidden" name="action" value="suspend">
<button type="submit" class="btn-delete-single">Sperren</button>
{% endif %}
</form>
<form action="{{ url_for('admin_cancel_instance', instance_id=item.id) }}" method="POST" class="form-delete-single">
<button type="submit" class="btn-delete-single" onclick="return confirm('Möchten Sie diese Instanz wirklich stornieren und vom Server löschen?');">
Stornieren
@@ -204,6 +219,17 @@ th {
color: #991b1b;
}
.btn-activate {
background-color: #eaf8f1;
border: 1px solid #8cc9a6;
color: #0f5c3a;
padding: 0.35rem 0.75rem;
border-radius: 6px;
font-weight: 600;
font-size: 0.8rem;
cursor: pointer;
}
.table-actions-bottom {
margin-top: 1.5rem;
padding-top: 1rem;
+8 -3
View File
@@ -14,13 +14,18 @@
<p><strong>Schule:</strong> {{ instance.school_name }}</p>
<p><strong>Subdomain:</strong> {{ instance.subdomain }}</p>
<p><strong>Status:</strong> {{ instance.status }}</p>
<p><strong>Zahlungsstatus:</strong> {{ instance.billing_state }}{% if instance.payment_due_at %} | Zahlungsziel: {{ instance.payment_due_at }}{% endif %}</p>
{% if instance.billing_state == "suspended" %}
<p style="color:#8b2a27; font-weight:700;">Die Instanz ist gesperrt, weil die Rechnung nicht innerhalb des 14-tägigen Zahlungsziels als bezahlt bestätigt wurde.</p>
{% elif instance.payment_status == "pending" and instance.payment_due_at %}
<p style="color:#7a5b00;">Die Instanz bleibt bis zum Zahlungsziel aktiv. Nach der Admin-Bestätigung „Bezahlt“ bleibt sie dauerhaft freigeschaltet.</p>
{% endif %}
{% if instance.last_message %}<p><strong>Hinweis:</strong> {{ instance.last_message }}</p>{% endif %}
<p><strong>Zugang:</strong> <a href="https://{{ instance.domain }}" target="_blank" rel="noopener noreferrer">https://{{ instance.domain }}</a></p>
{% if instance.is_trial %}
<p><strong>Testversion:</strong> aktiv bis {{ instance.expires_at }}</p>
{% if instance.trial_state == "active" or instance.trial_state == "in_progress" or instance.trial_state == "expired" and not instance.upgrade_requested %}
<form method="post" action="{{ url_for('request_trial_upgrade') }}" style="margin-top: 1rem;">
<button type="submit">Auf reguläre Lizenz upgraden</button>
</form>
<a href="{{ url_for('preise', upgrade=1) }}" style="display:inline-block; margin-top:1rem;">Reguläre Module buchen</a>
{% elif instance.upgrade_requested %}
<p>Ihre Upgrade-Anfrage wird vom Team bearbeitet.</p>
{% endif %}
+6
View File
@@ -300,6 +300,12 @@
<div id="checkout-form-state">
<span class="booking-review__eyebrow">Buchung prüfen & abschließen</span>
<h1>{{ title }}</h1>
{% if active_trial %}
<div class="booking-review__note" style="margin-top: 1rem; border-color: #b9d8c2; background: #f1fbf3;">
Ihre aktive Testversion ({{ active_trial.school_name or 'Ihre Schule' }}) wird bei dieser Buchung weiterverwendet und mit den ausgewählten Modulen reaktiviert. Die bisherigen Zugangsdaten und die Schul-URL bleiben erhalten.
</div>
{% endif %}
<div class="booking-review__grid">