diff --git a/backend/database.py b/backend/database.py index 2c37d10..be7d455 100644 --- a/backend/database.py +++ b/backend/database.py @@ -2145,6 +2145,25 @@ def _migrate(conn_factory): except Exception: pass # Spalte existiert bereits + # ---- Feature: is_active für Hunde (nach Abo-Downgrade) ---- + try: + conn.execute("ALTER TABLE dogs ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1") + logger.info("Migration: dogs.is_active hinzugefügt.") + except Exception: + pass + + # ---- Feature: Subscription-Laufzeit & Kündigung ---- + for col, default in [ + ("subscription_expires_at", "NULL"), + ("subscription_cancelled_at","NULL"), + ("needs_dog_selection", "0"), + ]: + try: + conn.execute(f"ALTER TABLE users ADD COLUMN {col} TEXT DEFAULT {default}") + logger.info(f"Migration: {col} hinzugefügt.") + except Exception: + pass + # exercise_progress + training_plan_progress: dog_id ergänzen existing_ep = [r[1] for r in conn.execute("PRAGMA table_info(exercise_progress)").fetchall()] if 'dog_id' not in existing_ep: diff --git a/backend/main.py b/backend/main.py index 44b051d..f91ca10 100644 --- a/backend/main.py +++ b/backend/main.py @@ -406,7 +406,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "944" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "945" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 09ab0b8..be0c8ed 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -1138,9 +1138,12 @@ async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)): raise HTTPException(400, "Bereits erledigt.") if req["tier"] not in _VALID_TIERS: raise HTTPException(400, "Ungültiger Tier.") + from datetime import timedelta + expires_at = (datetime.now(_TZ) + timedelta(days=365)).strftime('%Y-%m-%dT%H:%M:%SZ') conn.execute( - "UPDATE users SET subscription_tier=? WHERE id=?", - (req["tier"], req["user_id"]) + """UPDATE users SET subscription_tier=?, subscription_expires_at=?, + subscription_cancelled_at=NULL WHERE id=?""", + (req["tier"], expires_at, req["user_id"]) ) conn.execute( "UPDATE upgrade_requests SET fulfilled_at=strftime('%Y-%m-%dT%H:%M:%fZ','now'), fulfilled_by=? WHERE id=?", diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 863be66..59ad6e8 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -240,7 +240,8 @@ async def me(user=Depends(get_current_user)): profil_sichtbarkeit, avatar_url, created_at, is_founder, is_partner, founder_number, is_founder_pending, notes_ki_enabled, gassi_stunde_push, - preferred_theme, subscription_tier + preferred_theme, subscription_tier, + subscription_expires_at, subscription_cancelled_at, needs_dog_selection FROM users WHERE id=?""", (user["id"],) ).fetchone() @@ -394,3 +395,70 @@ async def reset_password(data: ResetPasswordRequest, request: Request): (hash_password(data.password), user["id"]) ) return {"ok": True} + + +@router.post("/subscription/cancel") +async def cancel_subscription(user=Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT subscription_tier, subscription_expires_at, subscription_cancelled_at FROM users WHERE id=?", + (user["id"],) + ).fetchone() + if not row or row["subscription_tier"] in ("standard", "standard_test"): + raise HTTPException(400, "Kein aktives Abo vorhanden.") + if row["subscription_cancelled_at"]: + raise HTTPException(400, "Abo ist bereits gekündigt.") + conn.execute( + "UPDATE users SET subscription_cancelled_at=strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE id=?", + (user["id"],) + ) + expires = row["subscription_expires_at"] + + # Bestätigungsmail + try: + from mailer import send_email, email_html + import html as _html + tier_label = {"pro": "Ban Yaro Pro", "breeder": "Züchter"}.get(row["subscription_tier"], row["subscription_tier"]) + expires_fmt = expires[:10] if expires else "—" + body_html = f""" +
Hallo {_html.escape(user['name'])},
+deine Kündigung für {tier_label} wurde bestätigt.
+Dein Abo ist weiterhin aktiv bis zum {expires_fmt}. + Ab diesem Datum wirst du automatisch auf den kostenlosen Tarif gesetzt.
+Deine Daten (Tagebuch, Gesundheit, Notizen) bleiben vollständig erhalten. + Wenn du mehrere Hunde hast, kannst du vor dem Ablauf einen als Haupthund festlegen.
+Wir hoffen, dich bald wieder begrüßen zu dürfen!
+Viele Grüße
René & das Ban Yaro Team
Hallo {_html.escape(u['name'])},
+dein {tier_label}-Abo ist heute abgelaufen. + Dein Account wurde auf den kostenlosen Tarif gesetzt.
+Deine Daten sind vollständig erhalten. Du kannst jederzeit wieder upgraden.
""" + if needs_sel: + body += "Wichtig: Du hattest mehrere Hunde. Öffne die App und wähle deinen Haupthund aus — alle anderen Profile bleiben gespeichert.
" + html = email_html(body, cta_url="https://banyaro.app", cta_label="Ban Yaro öffnen") + await send_email(u["email"], f"Dein {tier_label}-Abo ist abgelaufen", html, + f"Hallo {u['name']},\ndein {tier_label}-Abo ist abgelaufen. Daten bleiben erhalten.") + + # 30 Tage Warnung + elif days_left == 30: + body = f""" +Hallo {_html.escape(u['name'])},
+dein {tier_label}-Abo läuft in 30 Tagen + (am {expires.strftime('%d.%m.%Y')}) ab.
+Um weiterzumachen, überweise einfach den Jahresbetrag und schreib uns kurz — + wir verlängern deinen Zugang sofort.
""" + html = email_html(body, cta_url="https://banyaro.app", cta_label="Abo verlängern") + await send_email(u["email"], f"Dein {tier_label}-Abo läuft in 30 Tagen ab", html, + f"Hallo {u['name']},\ndein {tier_label}-Abo läuft in 30 Tagen ab ({expires}).") + + # 7 Tage Warnung + elif days_left == 7: + body = f""" +Hallo {_html.escape(u['name'])},
+dein {tier_label}-Abo läuft in 7 Tagen + (am {expires.strftime('%d.%m.%Y')}) ab.
+Jetzt verlängern und nahtlos weitermachen!
""" + html = email_html(body, cta_url="https://banyaro.app", cta_label="Abo verlängern") + await send_email(u["email"], f"Nur noch 7 Tage — {tier_label}-Abo läuft ab", html, + f"Hallo {u['name']},\nnur noch 7 Tage für dein {tier_label}-Abo.") + + except Exception as e: + logger.warning(f"subscription_check Fehler für {u['email']}: {e}") + + # ------------------------------------------------------------------ # JOB: Gesundheits-Erinnerungen # ------------------------------------------------------------------ diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 8621935..c8b9c6c 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -127,6 +127,12 @@ const API = (() => { upgradeRequest(tier, message) { return post('/auth/upgrade-request', { tier, message }); }, + cancelSubscription() { + return post('/auth/subscription/cancel', {}); + }, + selectPrimaryDog(dog_id) { + return post('/auth/subscription/select-dog', { dog_id }); + }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/app.js b/backend/static/js/app.js index c6289ea..f0a0d78 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 = '944'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '945'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen @@ -567,6 +567,11 @@ const App = (() => { navigate('onboarding'); } + // Abo abgelaufen mit mehreren Hunden → Haupthund auswählen + if (state.user.needs_dog_selection && state.dogs.length > 1) { + _showDogSelectionModal(); + } + // Theme aus DB-Profil übernehmen (überschreibt localStorage-Wert) _applyUserTheme(state.user); @@ -668,6 +673,57 @@ const App = (() => { document.getElementById('meta-theme-color')?.setAttribute('content', isDark ? '#0f1623' : '#C4843A'); } + function _showDogSelectionModal() { + const dogs = state.dogs; + const optionHtml = dogs.map(d => ` + `).join(''); + + UI.modal.open({ + title: 'Haupthund auswählen', + body: ` ++ Dein Abo ist ausgelaufen. Wähle einen Haupthund für deinen kostenlosen Account. + Alle anderen Hunde-Profile bleiben vollständig gespeichert — du kannst sie nach + einem erneuten Upgrade wieder aktivieren. +
+ `, + footer: ` + ` + }); + + document.getElementById('dog-select-confirm')?.addEventListener('click', async () => { + const chosen = document.querySelector('[name="select-dog"]:checked')?.value; + if (!chosen) { UI.toast.warning('Bitte einen Hund auswählen.'); return; } + const btn = document.getElementById('dog-select-confirm'); + btn.disabled = true; btn.textContent = '…'; + try { + await API.auth.selectPrimaryDog(parseInt(chosen)); + state.user.needs_dog_selection = 0; + state.activeDog = state.dogs.find(d => String(d.id) === chosen) || state.dogs[0]; + localStorage.setItem('by_active_dog', String(state.activeDog.id)); + UI.modal.close(); + UI.toast.success('Haupthund festgelegt.'); + _renderDogSwitcher(); + } catch (e) { + btn.disabled = false; btn.textContent = 'Auswahl bestätigen'; + UI.toast.error(e.message || 'Fehler.'); + } + }); + } + function _showAndroidBetaBanner() { // Nur auf Android, nur einmalig, nur für eingeloggte Nutzer if (!/android/i.test(navigator.userAgent)) return; diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index 1bacd62..89e527b 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -104,18 +104,43 @@ window.Page_settings = (() => { ${price} `; + const expires = u.subscription_expires_at; + const cancelled = u.subscription_cancelled_at; + const expiresDate = expires ? new Date(expires).toLocaleDateString('de-DE', {day:'numeric',month:'long',year:'numeric'}) : null; + const isPaid = (isPro || isBreeder) && !tier.endsWith('_test') && !isAdmin; + + const _expiryInfo = () => { + if (!isPaid || !expiresDate) return ''; + const color = cancelled ? '#e65100' : 'var(--c-text-secondary)'; + const text = cancelled + ? `Gekündigt — läuft bis ${expiresDate}` + : `Aktiv bis ${expiresDate}`; + return `