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

""" + html = email_html(body_html, cta_url="https://banyaro.app", cta_label="Ban Yaro öffnen") + plain = (f"Hallo {user['name']},\n\nKündigung bestätigt für {tier_label}.\n" + f"Aktiv bis: {expires_fmt}\n\nAlle Daten bleiben erhalten.\n\nViele Grüße\nRené") + await send_email(user["email"], f"Kündigung bestätigt — {tier_label}", html, plain) + except Exception: + pass + + return {"ok": True, "expires_at": expires} + + +@router.post("/subscription/select-dog") +async def select_primary_dog(body: dict, user=Depends(get_current_user)): + """Nach Downgrade: Haupthund auswählen, Rest bleibt erhalten aber inaktiv.""" + dog_id = body.get("dog_id") + if not dog_id: + raise HTTPException(400, "dog_id fehlt.") + with db() as conn: + dog = conn.execute( + "SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"]) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + # Alle anderen Hunde deaktivieren + conn.execute( + "UPDATE dogs SET is_active=0 WHERE user_id=? AND id!=?", (user["id"], dog_id) + ) + conn.execute( + "UPDATE dogs SET is_active=1 WHERE id=?", (dog_id,) + ) + conn.execute( + "UPDATE users SET needs_dog_selection=0 WHERE id=?", (user["id"],) + ) + return {"ok": True} diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index 832e777..45228ed 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -43,7 +43,7 @@ class DogUpdate(BaseModel): async def list_dogs(user=Depends(get_current_user)): with db() as conn: own = conn.execute( - "SELECT *, NULL AS shared_by, NULL AS share_role FROM dogs WHERE user_id=? AND (verstorben_am IS NULL) ORDER BY id", + "SELECT *, NULL AS shared_by, NULL AS share_role FROM dogs WHERE user_id=? AND (verstorben_am IS NULL) AND (is_active IS NULL OR is_active=1) ORDER BY id", (user["id"],) ).fetchall() shared = conn.execute( diff --git a/backend/scheduler.py b/backend/scheduler.py index 548e4c9..510b348 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -188,8 +188,15 @@ def start(): replace_existing=True, misfire_grace_time=3600, ) + _scheduler.add_job( + _job_subscription_check, + CronTrigger(hour=3, minute=0), + id="subscription_check", + replace_existing=True, + misfire_grace_time=3600, + ) _scheduler.start() - logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00, Foto-Challenge Mo 08:00. OSM-Cache: on-demand (kein Prewarm).") + logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00, Foto-Challenge Mo 08:00, Abo-Check 03:00. OSM-Cache: on-demand (kein Prewarm).") def stop(): @@ -197,6 +204,82 @@ def stop(): logger.info("Scheduler gestoppt.") +# ------------------------------------------------------------------ +# JOB: Abo-Ablauf prüfen (täglich 03:00) +# ------------------------------------------------------------------ +async def _job_subscription_check(): + """Abgelaufene Abos auf Standard setzen; Warnmails 30 und 7 Tage vorher.""" + from database import db as _db + from mailer import send_email, email_html + import html as _html + now = datetime.now(_TZ) + today = now.date() + + with _db() as conn: + users = conn.execute( + """SELECT id, name, email, subscription_tier, subscription_expires_at + FROM users + WHERE subscription_tier IN ('pro','breeder') + AND subscription_expires_at IS NOT NULL""" + ).fetchall() + + for u in users: + try: + expires = datetime.fromisoformat(u["subscription_expires_at"].replace('Z', '+00:00')).date() + days_left = (expires - today).days + tier_label = {"pro": "Ban Yaro Pro", "breeder": "Züchter"}.get(u["subscription_tier"], u["subscription_tier"]) + + # Abgelaufen → auf Standard setzen + if days_left < 0: + with _db() as conn: + dog_count = conn.execute( + "SELECT COUNT(*) FROM dogs WHERE user_id=? AND is_active!=0", (u["id"],) + ).fetchone()[0] + needs_sel = 1 if dog_count > 1 else 0 + conn.execute( + """UPDATE users SET subscription_tier='standard', + needs_dog_selection=? WHERE id=?""", + (needs_sel, u["id"]) + ) + logger.info(f"Abo abgelaufen: {u['email']} → standard (needs_dog_selection={needs_sel})") + body = f""" +

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. +

+
${optionHtml}
`, + 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 `
${text}
`; + }; + + const _cancelBtn = () => { + if (!isPaid || cancelled) return ''; + return ``; + }; + let statusHtml = ''; let actionsHtml = ''; if (isAdmin) { statusHtml = _badge('Admin', '#6366f1'); } else if (isBreeder) { - statusHtml = _badge('Züchter aktiv', '#C4843A'); + statusHtml = _badge(cancelled ? 'Züchter (gekündigt)' : 'Züchter aktiv', '#C4843A'); } else if (isPro) { - statusHtml = _badge('Pro aktiv', '#16a34a'); + statusHtml = _badge(cancelled ? 'Pro (gekündigt)' : 'Pro aktiv', '#16a34a'); actionsHtml = `
- ${_upgradeBtn('settings-upgrade-breeder-btn','Züchter werden','49 €/Jahr','#C4843A')} + ${!cancelled ? _upgradeBtn('settings-upgrade-breeder-btn','Züchter werden','49 €/Jahr','#C4843A') : ''}
`; } else { statusHtml = _badge('Kostenlos', '#888'); @@ -134,7 +159,9 @@ window.Page_settings = (() => { Aktueller Tarif: ${statusHtml} + ${_expiryInfo()} ${actionsHtml} + ${_cancelBtn()} `; } @@ -340,6 +367,71 @@ window.Page_settings = (() => { }); } + function _showCancelModal() { + const u = _appState.user; + const tier = u?.subscription_tier || 'standard'; + const label = { pro: 'Ban Yaro Pro', breeder: 'Züchter' }[tier] || tier; + const expires = u?.subscription_expires_at; + const expiresDate = expires + ? new Date(expires).toLocaleDateString('de-DE', {day:'numeric',month:'long',year:'numeric'}) + : null; + + UI.modal.open({ + title: `${label} kündigen`, + body: ` +
+ ${expiresDate ? ` +
+ Dein Abo läuft noch bis ${expiresDate} — du hast bis dahin vollen Zugriff. +
` : ''} +
+
✓ Alle deine Daten (Tagebuch, Gesundheit, Notizen) bleiben vollständig erhalten
+
✓ Deine Hunde-Profile bleiben gespeichert
+
✓ Du kannst jederzeit wieder upgraden
+ ${_appState.dogs?.length > 1 + ? `
⚠ Du hast mehrere Hunde — nach dem Ablauf wählst du einen als Haupthund
` + : ''} +
+
`, + footer: ` + + ` + }); + + document.getElementById('cancel-sub-confirm-btn')?.addEventListener('click', async () => { + const btn = document.getElementById('cancel-sub-confirm-btn'); + if (!btn) return; + btn.disabled = true; + btn.textContent = '…'; + try { + await API.auth.cancelSubscription(); + // User-State aktualisieren + const fresh = await API.auth.me(); + Object.assign(_appState.user, fresh); + UI.modal.close(); + UI.toast.success('Kündigung bestätigt. Eine Bestätigungsmail wurde gesendet.'); + _render(); + } catch (e) { + btn.disabled = false; + btn.textContent = 'Jetzt kündigen'; + UI.toast.error(e.message || 'Fehler beim Kündigen.'); + } + }); + } + // ---------------------------------------------------------- // RENDER // ---------------------------------------------------------- @@ -1102,6 +1194,9 @@ window.Page_settings = (() => { document.getElementById('settings-upgrade-breeder-btn')?.addEventListener('click', () => { _showUpgradeModal('breeder'); }); + document.getElementById('settings-cancel-sub-btn')?.addEventListener('click', () => { + _showCancelModal(); + }); document.getElementById('settings-worlds-btn')?.addEventListener('click', () => { if (window.Worlds?._openConfigModal) window.Worlds._openConfigModal(); diff --git a/backend/static/sw.js b/backend/static/sw.js index 9cbed57..b0c6ac2 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v944'; +const CACHE_VERSION = 'by-v945'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache