Implementiert:

Bestehende Kunden können nur noch zusätzliche Module buchen.
Bereits enthaltene Module werden auf der Preisseite deaktiviert.
Gültige Herabstufungen werden separat angeboten.
Herabstufungen behalten Instanz und Daten, erstellen keine neue Rechnung und aktualisieren den Modulumfang.
Bestehende Schul-URLs werden automatisch vorausgefüllt.
Serverseitige Validierung verhindert manipulierte Buchungsanfragen.
Geänderte Dateien: main.py, preise.html, schnell_buchung.html.

Validiert mit Python-Syntaxprüfung, Jinja-Template-Parsing und git diff --check. Der direkte Laufzeittest war wegen fehlendem Flask im System-Interpreter nicht möglich.
This commit is contained in:
2026-10-03 17:00:38 +02:00
parent c0607669c2
commit d38ba06484
3 changed files with 161 additions and 21 deletions
+108 -11
View File
@@ -2203,7 +2203,30 @@ def download_sample():
@app.route('/preise', methods=['GET'])
def preise():
trial_instance = _get_user_trial_instance(session.get("username")) if session.get("username") else None
return render_template("preise.html", trial_instance=trial_instance, booking_prices=BOOKING_PRICE_MAP)
owned_instance = _get_instance_for_user(session.get("username"), user_id=session.get("user_id")) if session.get("username") else None
paid_instance = owned_instance if owned_instance and not owned_instance.get("is_trial") else None
current_package_keys = _instance_package_keys(paid_instance)
current_module_keys = sorted(_package_modules(current_package_keys))
downgrade_options = []
if paid_instance:
current_modules = set(current_module_keys)
for package_key in ("startpaket", "inventarsystem", "buecherei", "terminverwaltung"):
package_modules = _package_modules([package_key])
if package_modules and package_modules < current_modules:
downgrade_options.append({
"id": package_key,
"name": BOOKING_PACKAGE_MAP[package_key],
"price": BOOKING_PRICE_MAP[package_key],
})
return render_template(
"preise.html",
trial_instance=trial_instance,
booking_prices=BOOKING_PRICE_MAP,
paid_instance=paid_instance,
current_package_keys=current_package_keys,
current_module_keys=current_module_keys,
downgrade_options=downgrade_options,
)
@app.route('/preise/book-option', methods=['POST'])
@@ -2234,6 +2257,51 @@ PACKAGE_MODULE_MAP = {
}
def _package_modules(package_list: list | tuple | set | str) -> set[str]:
values = [package_list] if isinstance(package_list, str) else package_list
modules = set()
for package in values or []:
package_key = str(package or "").strip().lower()
if package_key in ALL_MODULE_KEYS:
modules.add(package_key)
elif package_key in PACKAGE_MODULE_MAP:
modules.update(PACKAGE_MODULE_MAP[package_key])
elif "advanced" in package_key or "komplett" in package_key:
modules.update(ALL_MODULE_KEYS)
elif "start" in package_key:
modules.update(("library", "inventory", "student_cards"))
return modules
def _instance_package_keys(instance: dict | None) -> list[str]:
if not instance:
return []
return list(dict.fromkeys(
item.strip().lower()
for item in str(instance.get("package") or "").split(",")
if item.strip()
))
def _booking_restriction(instance: dict | None, package_list: list[str], booking_action: str) -> str | None:
if not instance or instance.get("is_trial"):
return None
current_modules = _package_modules(_instance_package_keys(instance))
requested_modules = _package_modules(package_list)
if not current_modules:
return "Der bestehende Paketumfang konnte nicht ermittelt werden. Bitte wenden Sie sich an unser Team."
if booking_action == "downgrade":
if not requested_modules or not requested_modules < current_modules:
return "Die gewählte Version ist keine gültige Herabstufung Ihres bestehenden Pakets."
return None
if not requested_modules - current_modules:
return "Sie haben bereits ein Paket mit diesem Umfang. Bitte wählen Sie nur zusätzliche Module oder nutzen Sie die Option zur Herabstufung."
return None
def build_module_config_string(package_list: list) -> str:
active_modules = set()
@@ -2852,6 +2920,9 @@ def _get_booking_form_defaults(username: str) -> dict:
def booking_payment():
packages_str = _sanitize_text(request.values.get("packages") or request.values.get("package") or "", 200).lower()
booking_flow = _sanitize_text(request.values.get("flow") or "payment", 24).lower()
booking_action = _sanitize_text(request.values.get("booking_action") or "additional", 24).lower()
if booking_action not in {"additional", "downgrade"}:
booking_action = "additional"
requested_trial = request.values.get("testversion", "").strip().lower() in {"1", "on", "true", "yes"}
package_requests_trial = "testversion" in {
@@ -2884,6 +2955,14 @@ def booking_payment():
return redirect(url_for("preise"))
package_list = [p.strip() for p in packages_str.split(',') if p.strip()]
owned_instance = _get_instance_for_user(session.get("username"), user_id=session.get("user_id"))
restriction = _booking_restriction(owned_instance, package_list, booking_action)
if restriction:
if is_json_request:
return jsonify({"success": False, "error": restriction}), 409
flash(restriction, "error")
return redirect(url_for("preise"))
selected_package_names = []
total_price = 0.0
@@ -2947,9 +3026,10 @@ def booking_payment():
if not form_data["tenant_slug"] and trial_instance:
form_data["tenant_slug"] = trial_instance.get("subdomain") or ""
owned_instance = _get_instance_for_user(session.get("username"), user_id=session.get("user_id"))
existing_subdomain = _sanitize_text((owned_instance or {}).get("subdomain") or (trial_instance or {}).get("subdomain") or "", 63)
form_data["existing_subdomain"] = existing_subdomain
if not form_data["tenant_slug"] and existing_subdomain:
form_data["tenant_slug"] = existing_subdomain
if request.method == "GET" and not form_data["contact_person"]:
form_data["contact_person"] = session.get("display_name") or session.get("username") or ""
@@ -2963,8 +3043,9 @@ def booking_payment():
"price_vat": vat_amount,
"price_gross": gross_price,
"flow": booking_flow,
"booking_action": booking_action,
"is_trial": is_trial,
"title": flow_config["title"],
"title": "Herabstufung Ihrer Version prüfen" if booking_action == "downgrade" else flow_config["title"],
"intro": flow_config["intro"],
"confirm_label": flow_config["confirm_label"],
"username": session.get("username"),
@@ -3139,7 +3220,7 @@ def booking_payment():
req_client.close()
invoice_id = None
if not is_trial:
if not is_trial and booking_action != "downgrade":
due_date = (date.today() + timedelta(days=PAYMENT_GRACE_DAYS)).isoformat()
invoice_client, invoice_col = _get_collection("invoices")
try:
@@ -3169,20 +3250,36 @@ def booking_payment():
invoice_client.close()
if reuse_instance:
module_config = build_module_config_string(package_list)
current_package_keys = _instance_package_keys(reuse_instance)
effective_package_keys = (
package_list
if booking_action == "downgrade"
else list(dict.fromkeys(current_package_keys + package_list))
)
module_config = " ".join(
f"{module}=on" if module in _package_modules(effective_package_keys) else f"{module}=off"
for module in ALL_MODULE_KEYS
)
user_email = session.get("email") or form_data.get("contact_email")
address_process = f"{form_data['billing_street']}, {form_data['billing_zip']} {form_data['billing_city']}"
price_str = f"{gross_price:.2f}".replace('.', ',')
billing_update = {
"package": package_raw,
"invoice_id": invoice_id,
"billing_state": "active",
"payment_status": "pending",
"payment_due_at": (date.today() + timedelta(days=PAYMENT_GRACE_DAYS)).isoformat(),
"package": ",".join(effective_package_keys),
"status": "ready",
"last_message": "Bestehende Instanz für die reguläre Buchung übernommen; Zahlungsziel beträgt 14 Tage.",
"last_message": (
"Paket wurde herabgestuft und die Module werden aktualisiert."
if booking_action == "downgrade"
else "Zusätzliche Module wurden zur bestehenden Instanz gebucht; Zahlungsziel beträgt 14 Tage."
),
"updated_at": _utc_now_iso(),
}
if booking_action != "downgrade":
billing_update.update({
"invoice_id": invoice_id,
"billing_state": "active",
"payment_status": "pending",
"payment_due_at": (date.today() + timedelta(days=PAYMENT_GRACE_DAYS)).isoformat(),
})
if reuse_instance.get("is_trial"):
billing_update.update({
"is_trial": False,
+41 -6
View File
@@ -153,8 +153,29 @@
</div>
{% endif %}
{% if paid_instance %}
<div class="notice" style="margin-bottom: 1.8rem; border-color: #b2d4dc; background: #f0f8fa;">
<strong>Ihre gebuchte Version bleibt bestehen.</strong>
<span> Bereits enthalten: {{ paid_instance.package or 'aktueller Paketumfang' }}. Sie können nur zusätzliche Module buchen.</span>
</div>
{% if downgrade_options %}
<section class="notice" style="margin-bottom: 1.8rem;">
<strong>Version herabstufen</strong>
<p style="margin: 0.5rem 0 1rem;">Wählen Sie eine kleinere Version. Ihre bestehende Instanz und Daten bleiben erhalten.</p>
<div class="custom-modal-actions" style="justify-content: flex-start; margin-top: 0;">
{% for option in downgrade_options %}
<button class="btn outline" type="button" onclick="goToCheckout('payment', 'downgrade', '{{ option.id }}')">
{{ option.name }} ({{ option.price|int }} € netto)
</button>
{% endfor %}
</div>
</section>
{% endif %}
{% endif %}
<section class="packages-grid" aria-label="Buchungspakete">
<!-- TESTVERSION -->
{% if not paid_instance %}
<article class="package-card">
<div class="package-card__header">
<div>
@@ -182,6 +203,7 @@
{% endif %}
</div>
</article>
{% endif %}
<!-- INVENTARSYSTEM -->
<article class="package-card">
<div class="package-card__header">
@@ -202,7 +224,7 @@
</ul>
<div class="package-card__actions">
{% if 'username' in session %}
<button class="btn add-to-cart-btn" data-id="inventarsystem" data-name="Inventarsystem" data-price="{{ booking_prices['inventarsystem']|int }}" onclick="handleAddToCart(this)">
<button class="btn add-to-cart-btn" data-id="inventarsystem" data-modules="inventory" data-name="Inventarsystem" data-price="{{ booking_prices['inventarsystem']|int }}" onclick="handleAddToCart(this)">
In den Warenkorb
</button>
{% else %}
@@ -230,7 +252,7 @@
</ul>
<div class="package-card__actions">
{% if 'username' in session %}
<button class="btn add-to-cart-btn" data-id="buecherei" data-name="Bibliothekssystem" data-price="{{ booking_prices['buecherei']|int }}" onclick="handleAddToCart(this)">
<button class="btn add-to-cart-btn" data-id="buecherei" data-modules="library" data-name="Bibliothekssystem" data-price="{{ booking_prices['buecherei']|int }}" onclick="handleAddToCart(this)">
In den Warenkorb
</button>
{% else %}
@@ -259,7 +281,7 @@
</ul>
<div class="package-card__actions">
{% if 'username' in session %}
<button class="btn add-to-cart-btn" data-id="terminverwaltung" data-name="Terminplaner" data-price="{{ booking_prices['terminverwaltung']|int }}" onclick="handleAddToCart(this)">
<button class="btn add-to-cart-btn" data-id="terminverwaltung" data-modules="terminplan" data-name="Terminplaner" data-price="{{ booking_prices['terminverwaltung']|int }}" onclick="handleAddToCart(this)">
In den Warenkorb
</button>
{% else %}
@@ -533,15 +555,16 @@
}
// Checkout Routing
function goToCheckout(flow) {
if (cart.length === 0) return;
function goToCheckout(flow, bookingAction = 'additional', packageId = '') {
if (cart.length === 0 && !packageId) return;
// Alle IDs der Cart zusammenführen (z.B. "startpaket,terminverwaltung")
const packageIds = cart.map(i => i.id).join(',');
const packageIds = packageId || cart.map(i => i.id).join(',');
const reviewUrl = new URL(bookingReviewBaseUrl, window.location.origin);
reviewUrl.searchParams.set('packages', packageIds);
reviewUrl.searchParams.set('flow', flow);
reviewUrl.searchParams.set('booking_action', bookingAction);
window.location.href = reviewUrl.toString();
}
@@ -562,5 +585,17 @@
closeUpsellModal();
}
});
const currentModules = new Set({{ current_module_keys|default([])|tojson }});
if (currentModules.size > 0) {
document.querySelectorAll('.add-to-cart-btn[data-modules]').forEach((button) => {
const modules = (button.dataset.modules || '').split(',').filter(Boolean);
if (modules.length > 0 && modules.every((module) => currentModules.has(module))) {
button.disabled = true;
button.textContent = 'Bereits enthalten';
button.title = 'Dieses Modul ist bereits in Ihrer Version enthalten.';
}
});
}
</script>
{% endblock %}
+12 -4
View File
@@ -298,7 +298,7 @@
<!-- STATE 1: FORMULAR (Checkout) -->
<div id="checkout-form-state">
<span class="booking-review__eyebrow">Buchung prüfen & abschließen</span>
<span class="booking-review__eyebrow">{% if booking_action == 'downgrade' %}Herabstufung prüfen{% else %}Buchung prüfen & abschließen{% endif %}</span>
<h1>{{ title }}</h1>
{% if active_trial %}
@@ -314,8 +314,6 @@
<span class="booking-review__label">Gewähltes Paket</span>
<div class="booking-review__value" style="font-size: 1.1rem;">{{ package }}</div>
<!-- Hier findet die automatische Berechnung statt -->
<!-- Voraussetzung: backend übergibt 'price' als numerische Variable (z.B. 149.00) -->
{% if price_net %}
<div class="price-breakdown">
<div class="price-row">
@@ -337,18 +335,28 @@
<div class="booking-review__card" style="border-left: 4px solid #1b8a3e;">
<span class="booking-review__label">Zahlungsart</span>
{% if booking_action == 'downgrade' %}
<div class="booking-review__value" style="color: #1b8a3e;">Bestehende Buchung anpassen</div>
<small style="color: #5e757d; font-weight: 600;">Keine neue Rechnung für die Herabstufung</small>
{% else %}
<div class="booking-review__value" style="color: #1b8a3e;">Kauf auf Rechnung</div>
<small style="color: #5e757d; font-weight: 600;">14 Tage Zahlungsziel nach Bereitstellung</small>
{% endif %}
</div>
</div>
<div class="booking-review__note">
{% if booking_action == 'downgrade' %}
Die bestehende Instanz und Ihre Daten bleiben erhalten. Nach dem Absenden wird der kleinere Paketumfang für die Instanz aktiviert.
{% else %}
Die Software-Instanz wird nach dem Absenden <strong>sofort für 14 Tage freigeschaltet</strong>. Die finale Laufzeit aktiviert sich automatisch nach Zahlungseingang durch Ihren Schulträger.
{% endif %}
</div>
<form id="checkout-form" method="POST" action="{{ url_for('booking_payment') }}">
<input type="hidden" name="package" value="{{ package_key }}">
<input type="hidden" name="flow" value="{{ flow }}">
<input type="hidden" name="booking_action" value="{{ booking_action|default('additional') }}">
<div class="booking-review__form-grid">
<div class="booking-review__section-title">1. Angaben zur Schule & Ansprechpartner</div>
@@ -424,7 +432,7 @@
<div id="form-error-alert" style="display:none; color:#b02a37; margin-top:1rem; font-weight:bold;"></div>
<div class="booking-review__actions">
<button id="booking_submit" class="btn" type="submit">Kostenpflichtig buchen & Instanz starten</button>
<button id="booking_submit" class="btn" type="submit">{% if booking_action == 'downgrade' %}Herabstufung bestätigen{% else %}Kostenpflichtig buchen & Instanz starten{% endif %}</button>
</div>
</form>
</div>