diff --git a/VERSION b/VERSION index 11b9a89..3720c6f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1271 \ No newline at end of file +1272 \ No newline at end of file diff --git a/backend/database.py b/backend/database.py index dba33c8..8065d48 100644 --- a/backend/database.py +++ b/backend/database.py @@ -623,6 +623,9 @@ def _migrate(conn_factory): ("users", "is_partner", "INTEGER NOT NULL DEFAULT 0"), ("users", "founder_number", "INTEGER"), ("users", "is_founder_pending", "INTEGER NOT NULL DEFAULT 0"), + # Gründer-Tickets: Kontingent an 50%-Rabatten, die ein Gründer an geworbene + # Freunde weitergeben kann (Liability-Cap; Admin pro Gründer anpassbar). + ("users", "founder_referral_tickets", "INTEGER NOT NULL DEFAULT 25"), # QR-Rückverfolgung: über welchen physischen QR-Code (Sticker/Flyer) kam die Registrierung ("users", "referred_qr", "TEXT"), # Partner-Code → Besitzer (für Self-Service: eigene QR-Kontingente + Stats einsehen) diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 715addd..375cf3a 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -1352,10 +1352,19 @@ def _get_discount_info(conn, user_id: int) -> dict: referred_by = row["referred_by"] or 0 if referred_by > 0: referrer = conn.execute( - "SELECT is_founder, is_founder_pending FROM users WHERE id=?", (referred_by,) + "SELECT is_founder, is_founder_pending, founder_referral_tickets FROM users WHERE id=?", (referred_by,) ).fetchone() if referrer and (referrer["is_founder"] or referrer["is_founder_pending"]): - return {"discount_pct": 100, "reason": "referred_by_founder", "referral_count": row["referral_count"]} + # 50%-Weitergabe nur innerhalb des Ticket-Kontingents des Gründers + # (Rang unter den verifizierten Geworbenen ≤ Tickets). 50%, NICHT 100%. + rank = conn.execute( + """SELECT COUNT(*) FROM users + WHERE referred_by=? AND email_verified=1 + AND created_at <= (SELECT created_at FROM users WHERE id=?)""", + (referred_by, user_id) + ).fetchone()[0] + if rank <= (referrer["founder_referral_tickets"] or 0): + return {"discount_pct": 50, "reason": "referred_by_founder", "referral_count": row["referral_count"]} count = row["referral_count"] for threshold, pct in [(50, 50), (20, 30), (10, 20)]: diff --git a/backend/routes/partner.py b/backend/routes/partner.py index 7690352..3102133 100644 --- a/backend/routes/partner.py +++ b/backend/routes/partner.py @@ -25,6 +25,7 @@ class PartnerCodeCreate(BaseModel): class GrantRequest(BaseModel): is_founder: Optional[int] = None is_partner: Optional[int] = None + founder_tickets: Optional[int] = Field(None, ge=0, le=200) # 50%-Rabatt-Kontingent # ------------------------------------------------------------------ @@ -129,8 +130,10 @@ def grant_user_status(user_id: int, data: GrantRequest, user=Depends(require_adm updates["is_founder"] = data.is_founder if data.is_partner is not None: updates["is_partner"] = data.is_partner + if data.founder_tickets is not None: + updates["founder_referral_tickets"] = data.founder_tickets if not updates: - raise HTTPException(400, "Mindestens is_founder oder is_partner muss angegeben werden.") + raise HTTPException(400, "Mindestens is_founder, is_partner oder founder_tickets muss angegeben werden.") with db() as conn: target = conn.execute( "SELECT id, is_founder, founder_number FROM users WHERE id=?", (user_id,) @@ -167,7 +170,7 @@ def grant_user_status(user_id: int, data: GrantRequest, user=Depends(require_adm (*updates.values(), user_id) ) row = conn.execute( - "SELECT id, name, email, is_founder, is_partner, founder_number FROM users WHERE id=?", + "SELECT id, name, email, is_founder, is_partner, founder_number, founder_referral_tickets FROM users WHERE id=?", (user_id,) ).fetchone() return dict(row) @@ -178,7 +181,7 @@ def search_users(q: str, user=Depends(require_admin)): """User-Suche für Admin (Name-Präfix, max. 10 Ergebnisse).""" with db() as conn: rows = conn.execute( - """SELECT id, name, email, is_founder, is_partner, rolle + """SELECT id, name, email, is_founder, is_partner, rolle, founder_referral_tickets FROM users WHERE name LIKE ? COLLATE NOCASE ORDER BY name LIMIT 10""", (f"{q}%",) diff --git a/backend/scheduler.py b/backend/scheduler.py index 700d047..8c8009c 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -326,12 +326,22 @@ async def _create_renewal_invoice_draft(user: dict, expires: date, tier_label: s discount_reason = "founder" elif (disc_row["referred_by"] or 0) > 0: ref = conn.execute( - "SELECT is_founder, is_founder_pending FROM users WHERE id=?", + "SELECT is_founder, is_founder_pending, founder_referral_tickets FROM users WHERE id=?", (disc_row["referred_by"],) ).fetchone() if ref and (ref["is_founder"] or ref["is_founder_pending"]): - discount_pct = 50 - discount_reason = "referred_by_founder" + # 50%-Weitergabe nur solange der Gründer Tickets hat: dieser Freund + # bekommt sie, wenn sein Rang unter den verifizierten Geworbenen + # (nach Anmeldedatum) das Ticket-Kontingent nicht übersteigt. + rank = conn.execute( + """SELECT COUNT(*) FROM users + WHERE referred_by=? AND email_verified=1 + AND created_at <= (SELECT created_at FROM users WHERE id=?)""", + (disc_row["referred_by"], user["id"]) + ).fetchone()[0] + if rank <= (ref["founder_referral_tickets"] or 0): + discount_pct = 50 + discount_reason = "referred_by_founder" if not discount_reason: for thr, pct in [(50, 50), (20, 30), (10, 20)]: if referral_count >= thr: diff --git a/backend/static/index.html b/backend/static/index.html index f861ff1..e45df41 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -620,11 +620,11 @@ - - - - - + + + + + @@ -634,7 +634,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 3098125..c9ee7fa 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1271'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1272'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 21120e7..f5ad0ee 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -2305,7 +2305,7 @@ window.Page_admin = (() => {

2. Registrierung mit Code — Wenn sich ein neuer User mit diesem Code registriert, wird er automatisch als Gründer markiert (Platz #1–100, lebenslang kostenlos). Du siehst in der Tabelle wie viele Einlösungen jeder Code hat.

3. Partner-Status vergeben — Den Influencer selbst suchst du unten bei «Nutzer-Status» und setzt Partner-Badge (blaues Badge im Profil) und Gründer-Lizenz. So ist auch er als Gründer #X sichtbar.

Max. 100 Gründer — Ist die Zahl bei einem Code leer, ist sie unbegrenzt. Die globale Grenze über alle Codes hinweg sind 100 Gründer-Plätze.

-

Freunde werben — Jeder eingeloggte User hat einen persönlichen Einladungslink (Einstellungen → Freunde werben). Bei 10 geworbenen Usern gibt es 20 % Rabatt, bei 20 → 30 %, bei 50 → 50 % — lebenslang, sobald Bezahlfunktionen aktiv sind.

+

Freunde werben — Jeder eingeloggte User hat einen persönlichen Einladungslink (Einstellungen → Freunde werben). Bei 10 geworbenen Usern gibt es 20 % Rabatt, bei 20 → 30 %, bei 50 → 50 % — dauerhaft auf Ban Yaro Pro. Gründer können zusätzlich ihren Geworbenen 50 % schenken (begrenzt durch ihre Gründer-Tickets, Standard 25).

@@ -2571,6 +2571,11 @@ window.Page_admin = (() => { Partner-Badge (Creator) +
+ + +
@@ -2772,6 +2777,7 @@ window.Page_admin = (() => { grantResult.innerHTML = users.map(u => `
@@ -2790,6 +2796,7 @@ window.Page_admin = (() => { if (form) { form.querySelector('[name="is_founder"]').checked = div.dataset.founder === '1'; form.querySelector('[name="is_partner"]').checked = div.dataset.partner === '1'; + form.querySelector('[name="founder_tickets"]').value = div.dataset.tickets ?? 25; } grantResult.innerHTML = `

✓ ${div.dataset.name} ausgewählt${div.dataset.founder==='1' ? ' · ⭐ Gründer' : ''}${div.dataset.partner==='1' ? ' · 🤝 Partner' : ''}

`; }); @@ -2806,14 +2813,14 @@ window.Page_admin = (() => { const btn = e.target.querySelector('[type="submit"]'); const isFounder = e.target.querySelector('[name="is_founder"]').checked ? 1 : 0; const isPartner = e.target.querySelector('[name="is_partner"]').checked ? 1 : 0; + const ticketsRaw = e.target.querySelector('[name="founder_tickets"]').value.trim(); await UI.asyncButton(btn, async () => { - const result = await API.post(`/admin/partner/users/${_grantUserId}/grant`, { - is_founder: isFounder, - is_partner: isPartner, - }); + const body = { is_founder: isFounder, is_partner: isPartner }; + if (ticketsRaw !== '') body.founder_tickets = parseInt(ticketsRaw); + const result = await API.post(`/admin/partner/users/${_grantUserId}/grant`, body); if (!result) throw new Error('Keine Antwort vom Server.'); UI.toast.success(`Status für ${result.name} gesetzt.`); - grantResult.innerHTML = `

✓ Gründer: ${result.is_founder ? 'Ja' : 'Nein'} | Partner: ${result.is_partner ? 'Ja' : 'Nein'}

`; + grantResult.innerHTML = `

✓ Gründer: ${result.is_founder ? 'Ja' : 'Nein'} | Partner: ${result.is_partner ? 'Ja' : 'Nein'} | 🎟 ${result.founder_referral_tickets ?? 25} Tickets

`; }).catch(e => UI.toast.error(e.message || 'Fehler beim Speichern.')); }); } diff --git a/backend/static/js/pages/gruender.js b/backend/static/js/pages/gruender.js index 1c4d56b..6855717 100644 --- a/backend/static/js/pages/gruender.js +++ b/backend/static/js/pages/gruender.js @@ -203,9 +203,9 @@ window.Page_gruender = (() => { ${benefit('🏅', 'Nummerierte Gründer-Badge', 'Ein „Gründer #N"-Abzeichen, dauerhaft sichtbar in deinem Profil und neben jedem Forum-Beitrag.')} ${benefit('👑', 'Lebenslang Ban Yaro Pro', - 'Alle Pro-Funktionen — kostenlos, für immer. Auch wenn Pro später etwas kostet, bleibt es für Gründer gratis.')} - ${benefit('🤝', 'Freunde mitbringen lohnt sich', - 'Wer sich über deine Einladung registriert, bekommt Ban Yaro Pro dauerhaft zum halben Preis.')} + 'Alle Pro-Funktionen — für dich dauerhaft kostenlos, solange es Ban Yaro gibt.')} + ${benefit('🎟️', '25 Freunde zum halben Preis', + 'Du bekommst 25 Einladungen: Wer sich darüber registriert, erhält Ban Yaro Pro dauerhaft für die Hälfte. Dein Geschenk an deine Liebsten.')} ${benefit('🌱', 'Teil der Geschichte', 'Du gehörst zu den Menschen, die Ban Yaro von Anfang an getragen haben — das bleibt.')}
diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 6a36d26..be5d09a 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -882,7 +882,7 @@ window.Page_settings = (() => {
${UI.icon('arrow-square-out')} Freunde werben — dauerhafter Rabatt
- 10 Freunde → 20% · 20 Freunde → 30% · 50 Freunde → 50% — lebenslang, sobald Bezahlfunktionen aktiv sind. + 10 Freunde → 20% · 20 Freunde → 30% · 50 Freunde → 50% — dauerhaft auf Ban Yaro Pro.
Lade…
@@ -1821,7 +1821,7 @@ window.Page_settings = (() => {

- Der Rabatt gilt für dich — sobald Bezahlfunktionen aktiv sind, dauerhaft und automatisch. + Der Rabatt gilt für dich auf Ban Yaro Pro — dauerhaft und automatisch.

`; diff --git a/backend/static/landing.html b/backend/static/landing.html index 0f51de6..3045c5e 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index a0d0ddf..2062953 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1271'; +const VER = '1272'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/tests/test_founder_tickets.py b/tests/test_founder_tickets.py new file mode 100644 index 0000000..f5f9b57 --- /dev/null +++ b/tests/test_founder_tickets.py @@ -0,0 +1,87 @@ +"""Gründer-Tickets: 50%-Rabatt-Weitergabe ist pro Gründer auf sein Kontingent gedeckelt. + +Hintergrund: Ein Gründer kann geworbenen Freunden 50% auf Pro schenken. Ohne Cap +könnten 100 Gründer unbegrenzt viele 50%-Rabatte vergeben — unkalkulierbare Liability. +Jeder Gründer hat daher ein Ticket-Kontingent (Standard 25), das die ersten N +verifizierten Geworbenen abdeckt. +""" + +import secrets +from datetime import datetime, timedelta + + +def _make_founder(email, tickets=25): + from database import db + with db() as conn: + uid = conn.execute("SELECT id FROM users WHERE email=?", (email,)).fetchone()["id"] + conn.execute( + "UPDATE users SET is_founder=1, founder_number=99, founder_referral_tickets=? WHERE id=?", + (tickets, uid), + ) + return uid + + +def _add_referred(founder_id, n, verified=True, base_minutes=0): + """Legt n direkt in der DB an, die vom Gründer geworben wurden (mit gestaffeltem created_at).""" + from database import db + ids = [] + with db() as conn: + for i in range(n): + ts = (datetime(2026, 1, 1) + timedelta(minutes=base_minutes + i)).isoformat() + conn.execute( + """INSERT INTO users (email, name, pw_hash, referred_by, email_verified, created_at) + VALUES (?,?,?,?,?,?)""", + (f"ref-{secrets.token_hex(5)}@example.com", f"r{secrets.token_hex(3)}", + "x", founder_id, 1 if verified else 0, ts), + ) + ids.append(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + return ids + + +def _discount(client, admin, uid): + r = client.get(f"/api/admin/users/{uid}/discount", headers=admin["headers"]) + assert r.status_code == 200, r.text + return r.json() + + +def test_referred_by_founder_is_50_not_100(client, admin, user): + """Bugfix-Absicherung: Geworbene eines Gründers bekommen 50%, nicht 100%.""" + fid = _make_founder(user["email"], tickets=25) + friend = _add_referred(fid, 1)[0] + d = _discount(client, admin, friend) + assert d["discount_pct"] == 50 + assert d["reason"] == "referred_by_founder" + + +def test_tickets_cap_the_50_percent(client, admin, user): + """Mit 2 Tickets bekommen nur die ersten 2 Geworbenen 50%, der 3. nichts.""" + fid = _make_founder(user["email"], tickets=2) + f1, f2, f3 = _add_referred(fid, 3) + assert _discount(client, admin, f1)["discount_pct"] == 50 + assert _discount(client, admin, f2)["discount_pct"] == 50 + d3 = _discount(client, admin, f3) + assert d3["discount_pct"] == 0 + assert d3["reason"] is None + + +def test_unverified_dont_consume_tickets(client, admin, user): + """Unbestätigte Geworbene verbrauchen kein Ticket — ein späterer bestätigter bekommt 50%.""" + fid = _make_founder(user["email"], tickets=1) + # 2 unbestätigte zuerst, dann 1 bestätigter + _add_referred(fid, 2, verified=False, base_minutes=0) + later = _add_referred(fid, 1, verified=True, base_minutes=10)[0] + assert _discount(client, admin, later)["discount_pct"] == 50 + + +def test_admin_grant_sets_tickets(client, admin, user): + """Admin kann das Ticket-Kontingent über den Grant-Endpoint setzen.""" + from database import db + with db() as conn: + uid = conn.execute("SELECT id FROM users WHERE email=?", (user["email"],)).fetchone()["id"] + r = client.post(f"/api/admin/partner/users/{uid}/grant", headers=admin["headers"], + json={"is_founder": 1, "founder_tickets": 50}) + assert r.status_code == 200, r.text + assert r.json()["founder_referral_tickets"] == 50 + with db() as conn: + val = conn.execute("SELECT founder_referral_tickets FROM users WHERE id=?", (uid,)).fetchone()[0] + assert val == 50