feat: add payment tracking for orders

- Add payment method selection (PayPal/Bar) to order form
- Store payment_method and payment_status in database
- Add payment status badges in admin orders view
- Add "mark as paid" functionality for admins
- PayPal account configurable via PAYPAL_ACCOUNT env variable
- Frontend loads PayPal account dynamically from /wawi/config endpoint
- Update email notifications to include payment method

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 08:28:33 +01:00
parent fd3f49a2e1
commit aa0de2fe4a
5 changed files with 131 additions and 6 deletions

View File

@@ -41,6 +41,7 @@ Für Bestellungen per Mail zusätzlich:
Optional: Optional:
- `APP_API_KEY` Schutz fuer `/wawi/api/bestand` und `/wawi/order` - `APP_API_KEY` Schutz fuer `/wawi/api/bestand` und `/wawi/order`
- `COOKIE_SECURE` `0` fuer http lokal, `1` fuer https - `COOKIE_SECURE` `0` fuer http lokal, `1` fuer https
- `PAYPAL_ACCOUNT` PayPal-E-Mail für Bestellformular (z.B. `paypal@beispiel.de`)
## Benutzerverwaltung ## Benutzerverwaltung
Beim ersten Start wird **ein Admin** aus ENV erzeugt: Beim ersten Start wird **ein Admin** aus ENV erzeugt:
@@ -59,10 +60,12 @@ python import_from_html.py /pfad/zu/hellas_bestand.html --truncate
## LiveShopAnsicht (index.html) ## LiveShopAnsicht (index.html)
`index.html` lädt den Bestand aus der WaWiApp: `index.html` lädt den Bestand aus der WaWiApp:
- APIProxy: `/wawi/proxy/bestand` - APIProxy: `/wawi/proxy/bestand`
- Konfiguration: `/wawi/config` (lädt PayPal-Konto aus ENV)
Wenn du diese Datei auf einem Webserver auslieferst, stelle sicher, dass die WaWiApp unter `/wawi` erreichbar ist. Wenn du diese Datei auf einem Webserver auslieferst, stelle sicher, dass die WaWiApp unter `/wawi` erreichbar ist.
Wenn du Bestellungen direkt aus `index.html` abschickst, muss der `X-Order-Key` bzw. `?key=` dem `APP_API_KEY` entsprechen. Wenn du Bestellungen direkt aus `index.html` abschickst, muss der `X-Order-Key` bzw. `?key=` dem `APP_API_KEY` entsprechen.
Der Key wird in `index.html` im ScriptBlock gesetzt: `const ORDER_KEY = ""` (neben der Formularlogik). Trage dort deinen Key ein. Der Key wird in `index.html` im ScriptBlock gesetzt: `const ORDER_KEY = ""`. Trage dort deinen Key ein (oder leer lassen, wenn kein API-Key erforderlich).
Das PayPal-Konto wird automatisch aus der ENV-Variable `PAYPAL_ACCOUNT` geladen.
## Umgebungsvariablen (ENV) ## Umgebungsvariablen (ENV)
**Pflicht/Empfohlen für Produktion** **Pflicht/Empfohlen für Produktion**
@@ -80,6 +83,7 @@ Der Key wird in `index.html` im ScriptBlock gesetzt: `const ORDER_KEY = ""` (
**Optional** **Optional**
- `APP_API_KEY` gemeinsamer APIKey für `/wawi/api/bestand` **und** `/wawi/order` - `APP_API_KEY` gemeinsamer APIKey für `/wawi/api/bestand` **und** `/wawi/order`
- `COOKIE_SECURE` `1` (default) setzt SecureCookie, `0` deaktiviert für http - `COOKIE_SECURE` `1` (default) setzt SecureCookie, `0` deaktiviert für http
- `PAYPAL_ACCOUNT` PayPal-E-Mail-Adresse für Bestellungen (wird im Bestellformular angezeigt)
## Deployment (systemd + Gunicorn) ## Deployment (systemd + Gunicorn)
1) App nach `/var/www/hellas/wawi` kopieren 1) App nach `/var/www/hellas/wawi` kopieren
@@ -112,6 +116,7 @@ Environment="SMTP_PASS=dein_pass"
Environment="SMTP_FROM=bestand@example.com" Environment="SMTP_FROM=bestand@example.com"
Environment="ORDER_TO=admin@example.com, zweite@example.com" Environment="ORDER_TO=admin@example.com, zweite@example.com"
Environment="APP_API_KEY=api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" Environment="APP_API_KEY=api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
Environment="PAYPAL_ACCOUNT=dein-paypal@beispiel.de"
ExecStart=/var/www/hellas/wawi/.venv/bin/gunicorn -w 3 -b 127.0.0.1:8000 app:app ExecStart=/var/www/hellas/wawi/.venv/bin/gunicorn -w 3 -b 127.0.0.1:8000 app:app
Restart=always Restart=always

View File

@@ -428,6 +428,22 @@
<label style="grid-column: 1 / -1;">Notiz <label style="grid-column: 1 / -1;">Notiz
<textarea id="fNotiz" name="notiz" rows="3"></textarea> <textarea id="fNotiz" name="notiz" rows="3"></textarea>
</label> </label>
<div style="grid-column: 1 / -1; padding: 12px; background: #f0f9ff; border: 1px solid #bfdbfe; border-radius: 4px; margin-top: 8px;">
<strong>Zahlungsart wählen:</strong>
<div style="margin-top: 8px;">
<label style="display: flex; align-items: center; margin-bottom: 8px;">
<input type="radio" name="payment_method" value="paypal" checked style="margin-right: 8px;" />
<span><strong>PayPal (Familie & Freunde)</strong></span>
</label>
<div id="paypalInfo" style="margin-left: 28px; margin-bottom: 12px; font-size: 0.95em; color: #1e40af;">
Bitte sende den Betrag über PayPal an: <strong id="paypalAccount"></strong>
</div>
<label style="display: flex; align-items: center;">
<input type="radio" name="payment_method" value="bar" style="margin-right: 8px;" />
<span><strong>Bar bei Abholung</strong></span>
</label>
</div>
</div>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn" id="orderCancel">Abbrechen</button> <button type="button" class="btn" id="orderCancel">Abbrechen</button>
@@ -568,6 +584,20 @@ const form = document.getElementById("orderForm");
const setField = (id, v) => document.getElementById(id).value = v || ""; const setField = (id, v) => document.getElementById(id).value = v || "";
const ORDER_KEY = ""; const ORDER_KEY = "";
// PayPal-Konto vom Backend laden
fetch("/wawi/config")
.then(res => res.json())
.then(config => {
if (config.paypal_account) {
document.getElementById("paypalAccount").textContent = config.paypal_account;
} else {
document.getElementById("paypalAccount").textContent = "Nicht konfiguriert";
}
})
.catch(() => {
document.getElementById("paypalAccount").textContent = "Fehler beim Laden";
});
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
const imgBtn = e.target.closest(".thumb-btn"); const imgBtn = e.target.closest(".thumb-btn");
if (imgBtn) { if (imgBtn) {

View File

@@ -167,6 +167,7 @@ def init_db() -> None:
ensure_price_column(db) ensure_price_column(db)
ensure_image_column(db) ensure_image_column(db)
ensure_orders_columns(db) ensure_orders_columns(db)
ensure_payment_columns(db)
ensure_indexes(db) ensure_indexes(db)
ensure_admin_user(db) ensure_admin_user(db)
@@ -208,6 +209,22 @@ def ensure_orders_columns(db: sqlite3.Connection) -> None:
db.commit() db.commit()
def ensure_payment_columns(db: sqlite3.Connection) -> None:
"""Fügt Zahlungs-Spalten nachträglich zur orders-Tabelle hinzu."""
cols = db.execute("PRAGMA table_info(orders)").fetchall()
names = {c["name"] for c in cols}
if "payment_method" not in names:
db.execute("ALTER TABLE orders ADD COLUMN payment_method TEXT DEFAULT 'bar'")
if "payment_status" not in names:
db.execute("ALTER TABLE orders ADD COLUMN payment_status TEXT DEFAULT 'unpaid'")
if "paid_at" not in names:
db.execute("ALTER TABLE orders ADD COLUMN paid_at TEXT")
if "paid_by" not in names:
db.execute("ALTER TABLE orders ADD COLUMN paid_by TEXT")
db.commit()
logger.info("Zahlungs-Spalten für orders überprüft/erstellt")
def ensure_indexes(db: sqlite3.Connection) -> None: def ensure_indexes(db: sqlite3.Connection) -> None:
"""Erstellt Indizes für bessere Query-Performance (idempotent).""" """Erstellt Indizes für bessere Query-Performance (idempotent)."""
# Index für items.artikel (häufig gesucht/gefiltert) # Index für items.artikel (häufig gesucht/gefiltert)
@@ -648,6 +665,14 @@ def proxy_bestand():
return jsonify(build_bestand()) return jsonify(build_bestand())
@bp.route("/config", methods=["GET"])
def config():
"""Öffentliche Konfiguration für Frontend (z. B. PayPal-Konto)."""
return jsonify({
"paypal_account": os.environ.get("PAYPAL_ACCOUNT", "")
})
def build_bestand() -> list[dict]: def build_bestand() -> list[dict]:
"""Aggregiert DBZeilen in die Struktur der LiveBestand Ansicht.""" """Aggregiert DBZeilen in die Struktur der LiveBestand Ansicht."""
rows = get_db().execute( rows = get_db().execute(
@@ -724,11 +749,15 @@ def order():
if int(data.get("menge") or 0) <= 0: if int(data.get("menge") or 0) <= 0:
return jsonify({"error": "Menge muss größer als 0 sein."}), 400 return jsonify({"error": "Menge muss größer als 0 sein."}), 400
payment_method = (data.get("payment_method") or "bar").strip().lower()
if payment_method not in ("paypal", "bar"):
payment_method = "bar"
db = get_db() db = get_db()
db.execute( db.execute(
""" """
INSERT INTO orders (name, handy, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at) INSERT INTO orders (name, handy, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at, payment_method, payment_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, 0, NULL, NULL) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, 0, NULL, NULL, ?, 'unpaid')
""", """,
( (
data.get("name"), data.get("name"),
@@ -739,6 +768,7 @@ def order():
int(data.get("menge") or 0), int(data.get("menge") or 0),
data.get("notiz"), data.get("notiz"),
now_iso(), now_iso(),
payment_method,
), ),
) )
db.commit() db.commit()
@@ -759,6 +789,7 @@ def order():
msg["From"] = smtp_from msg["From"] = smtp_from
recipients = [a.strip() for a in to_addr.split(",") if a.strip()] recipients = [a.strip() for a in to_addr.split(",") if a.strip()]
msg["To"] = ", ".join(recipients) msg["To"] = ", ".join(recipients)
payment_label = "PayPal (Familie & Freunde)" if payment_method == "paypal" else "Bar/Abholung"
body = ( body = (
"Neue Bestellung:\n" "Neue Bestellung:\n"
f"Name: {data.get('name')}\n" f"Name: {data.get('name')}\n"
@@ -767,6 +798,7 @@ def order():
f"Artikel: {data.get('artikel')}\n" f"Artikel: {data.get('artikel')}\n"
f"Größe: {data.get('groesse')}\n" f"Größe: {data.get('groesse')}\n"
f"Menge: {data.get('menge')}\n" f"Menge: {data.get('menge')}\n"
f"Zahlungsart: {payment_label}\n"
f"Notiz: {data.get('notiz') or '-'}\n" f"Notiz: {data.get('notiz') or '-'}\n"
"WaWi: https://hellas.welker.me/wawi\n" "WaWi: https://hellas.welker.me/wawi\n"
) )
@@ -799,7 +831,7 @@ def orders():
"""Bestellliste in der Verwaltung.""" """Bestellliste in der Verwaltung."""
rows = get_db().execute( rows = get_db().execute(
""" """
SELECT id, name, handy, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at SELECT id, name, handy, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at, payment_method, payment_status, paid_at, paid_by
FROM orders FROM orders
ORDER BY id DESC ORDER BY id DESC
LIMIT 500 LIMIT 500
@@ -861,6 +893,25 @@ def complete_order(order_id: int):
return redirect(url_for("bp.orders")) return redirect(url_for("bp.orders"))
@bp.route("/orders/mark_paid/<int:order_id>", methods=["POST"])
@login_required
def mark_paid(order_id: int):
"""Bestellung als bezahlt markieren."""
user = session.get("user") or "unknown"
db = get_db()
db.execute(
"""
UPDATE orders
SET payment_status = 'paid', paid_by = ?, paid_at = ?
WHERE id = ? AND payment_status != 'paid'
""",
(user, now_iso(), order_id),
)
db.commit()
logger.info(f"Bestellung #{order_id} als bezahlt markiert von {user}")
return redirect(url_for("bp.orders"))
@bp.route("/orders/cancel/<int:order_id>", methods=["POST"]) @bp.route("/orders/cancel/<int:order_id>", methods=["POST"])
@login_required @login_required
def cancel_order(order_id: int): def cancel_order(order_id: int):

19
wawi/static/style.css Normal file → Executable file
View File

@@ -198,6 +198,25 @@ input[type="text"], input[type="number"] {
} }
.to-top:hover { transform: translateY(-2px); } .to-top:hover { transform: translateY(-2px); }
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: 4px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.badge.success {
background: rgba(123, 213, 141, 0.2);
color: var(--ok);
border: 1px solid rgba(123, 213, 141, 0.4);
}
.badge.warning {
background: rgba(243, 213, 42, 0.2);
color: var(--accent);
border: 1px solid rgba(243, 213, 42, 0.4);
}
@media (max-width: 720px) { @media (max-width: 720px) {
.actions { display: flex; gap: 6px; flex-wrap: wrap; } .actions { display: flex; gap: 6px; flex-wrap: wrap; }
.btn.small { padding: 6px 8px; } .btn.small { padding: 6px 8px; }

View File

@@ -20,6 +20,8 @@
<th>Artikel</th> <th>Artikel</th>
<th>Größe</th> <th>Größe</th>
<th>Menge</th> <th>Menge</th>
<th>Zahlungsart</th>
<th>Zahlung</th>
<th>Notiz</th> <th>Notiz</th>
<th>Status</th> <th>Status</th>
<th class="actions">Aktion</th> <th class="actions">Aktion</th>
@@ -35,6 +37,16 @@
<td>{{ o.artikel }}</td> <td>{{ o.artikel }}</td>
<td>{{ o.groesse }}</td> <td>{{ o.groesse }}</td>
<td>{{ o.menge }}</td> <td>{{ o.menge }}</td>
<td>
{% if o.payment_method == "paypal" %}PayPal
{% else %}Bar
{% endif %}
</td>
<td>
{% if o.payment_status == "paid" %}<span class="badge success">Bezahlt</span>
{% else %}<span class="badge warning">Unbezahlt</span>
{% endif %}
</td>
<td>{{ o.notiz or "" }}</td> <td>{{ o.notiz or "" }}</td>
<td> <td>
{% if o.canceled %}Storniert {% if o.canceled %}Storniert
@@ -48,6 +60,12 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button class="btn small" type="submit">Erledigt</button> <button class="btn small" type="submit">Erledigt</button>
</form> </form>
{% if o.payment_status != "paid" %}
<form method="post" action="{{ url_for('bp.mark_paid', order_id=o.id) }}" onsubmit="return confirm('Als bezahlt markieren?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button class="btn small success" type="submit">Bezahlt</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('bp.cancel_order', order_id=o.id) }}" onsubmit="return confirm('Bestellung wirklich stornieren?');"> <form method="post" action="{{ url_for('bp.cancel_order', order_id=o.id) }}" onsubmit="return confirm('Bestellung wirklich stornieren?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button class="btn small danger" type="submit">Stornieren</button> <button class="btn small danger" type="submit">Stornieren</button>
@@ -58,12 +76,14 @@
</td> </td>
</tr> </tr>
<tr class="order-history"> <tr class="order-history">
<td colspan="10"> <td colspan="12">
<details class="inline-details"> <details class="inline-details">
<summary>Historie</summary> <summary>Historie</summary>
<div class="history-grid"> <div class="history-grid">
<div><strong>Abgeschlossen von:</strong> {{ o.completed_by or "" }}</div> <div><strong>Abgeschlossen von:</strong> {{ o.completed_by or "" }}</div>
<div><strong>Abgeschlossen am:</strong> {{ o.completed_at or "" }}</div> <div><strong>Abgeschlossen am:</strong> {{ o.completed_at or "" }}</div>
<div><strong>Bezahlt markiert von:</strong> {{ o.paid_by or "" }}</div>
<div><strong>Bezahlt markiert am:</strong> {{ o.paid_at or "" }}</div>
<div><strong>Storniert von:</strong> {{ o.canceled_by or "" }}</div> <div><strong>Storniert von:</strong> {{ o.canceled_by or "" }}</div>
<div><strong>Storniert am:</strong> {{ o.canceled_at or "" }}</div> <div><strong>Storniert am:</strong> {{ o.canceled_at or "" }}</div>
</div> </div>
@@ -72,7 +92,7 @@
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="10" class="empty">Keine Bestellungen.</td> <td colspan="12" class="empty">Keine Bestellungen.</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>