Feat: Rechnungsadresse — Profil, Upgrade-Modal Hinweis, Rechnung-erstellen-Button in Upgrade-Cards (SW by-v967)

This commit is contained in:
rene 2026-05-15 10:59:12 +02:00
parent 95b70d5119
commit 0a466ef6ce
9 changed files with 89 additions and 20 deletions

View file

@ -2444,6 +2444,12 @@ def _migrate(conn_factory):
except Exception as e:
logger.warning(f"Migration invoices: {e}")
try:
conn.execute("ALTER TABLE users ADD COLUMN billing_address TEXT")
logger.info("Migration: billing_address bereit.")
except Exception:
pass
def _seed_help_articles(conn):
"""Befüllt help_articles mit Starter-FAQs — nur wenn die Tabelle noch leer ist."""

View file

@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request):
raise _HE(404, "Nicht gefunden.")
return _media_response(filepath)
APP_VER = "966" # muss mit APP_VER in app.js übereinstimmen
APP_VER = "967" # muss mit APP_VER in app.js übereinstimmen
@app.get("/.well-known/assetlinks.json")
async def assetlinks():

View file

@ -1137,7 +1137,7 @@ async def list_upgrade_requests(user=Depends(require_admin)):
with db() as conn:
rows = conn.execute("""
SELECT r.id, r.user_id, r.tier, r.message, r.created_at, r.fulfilled_at,
u.name, u.email
u.name, u.email, u.billing_address
FROM upgrade_requests r
JOIN users u ON u.id = r.user_id
ORDER BY r.fulfilled_at IS NOT NULL, r.created_at DESC

View file

@ -241,7 +241,8 @@ async def me(user=Depends(get_current_user)):
is_founder, is_partner, founder_number, is_founder_pending,
notes_ki_enabled, gassi_stunde_push,
preferred_theme, subscription_tier,
subscription_expires_at, subscription_cancelled_at, needs_dog_selection
subscription_expires_at, subscription_cancelled_at, needs_dog_selection,
billing_address
FROM users WHERE id=?""",
(user["id"],)
).fetchone()

View file

@ -28,6 +28,7 @@ class ProfileUpdate(BaseModel):
notes_ki_enabled: Optional[int] = None
gassi_stunde_push: Optional[int] = None
preferred_theme: Optional[str] = None
billing_address: Optional[str] = None
def _load_user(user_id: int) -> dict:
@ -35,7 +36,7 @@ def _load_user(user_id: int) -> dict:
row = conn.execute(
"""SELECT id, name, real_name, email, rolle, is_premium, email_verified,
bio, wohnort, erfahrung, social_link,
profil_sichtbarkeit, avatar_url, created_at
profil_sichtbarkeit, avatar_url, created_at, billing_address
FROM users WHERE id=?""",
(user_id,)
).fetchone()

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '966'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '967'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app';
// Cache-Bust-Parameter nach Update-Reload sofort entfernen

View file

@ -3540,12 +3540,22 @@ window.Page_admin = (() => {
</div>` : ''}
</div>
</div>
<button class="btn adm-fulfill-btn" data-id="${r.id}" data-name="${_esc(r.name)}" data-tier="${r.tier}"
style="width:100%;margin-top:var(--space-3);background:#16a34a;color:#fff;border:none;
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
cursor:pointer;font-size:var(--text-sm);font-weight:600">
Freischalten
</button>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2);margin-top:var(--space-3)">
<button class="btn adm-invoice-btn"
data-name="${_esc(r.name)}" data-email="${_esc(r.email)}"
data-tier="${r.tier}" data-address="${_esc(r.billing_address || '')}"
style="background:#e67e22;color:#fff;border:none;
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
cursor:pointer;font-size:var(--text-sm);font-weight:600">
${UI.icon('receipt')} Rechnung erstellen
</button>
<button class="btn adm-fulfill-btn" data-id="${r.id}" data-name="${_esc(r.name)}" data-tier="${r.tier}"
style="background:#16a34a;color:#fff;border:none;
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
cursor:pointer;font-size:var(--text-sm);font-weight:600">
Freischalten
</button>
</div>
</div>`;
// Erledigte als kompakte Tabellenzeilen
@ -3610,6 +3620,31 @@ window.Page_admin = (() => {
}
});
});
// "Rechnung erstellen" — öffnet Invoice-Modal mit vorbefüllten Nutzerdaten
const TIER_ITEMS = {
pro: { description: 'Ban Yaro Pro Jahresabo', unit_price: 29.00 },
breeder: { description: 'Ban Yaro Züchter Jahresabo', unit_price: 49.00 },
};
const _year = new Date().getFullYear();
const _period = `01.01.${_year} 31.12.${_year}`;
el.querySelectorAll('.adm-invoice-btn').forEach(btn => {
btn.addEventListener('click', () => {
const { name, email, tier, address } = btn.dataset;
const tierItem = TIER_ITEMS[tier] || { description: 'Ban Yaro Abo', unit_price: 0 };
_openNeueRechnungModal(() => {
_tab = 'rechnungen';
_renderTab();
}, {
recipient_name: name,
recipient_email: email,
recipient_address: address || '',
service_period: _period,
items: [{ description: tierItem.description, quantity: 1, unit_price: tierItem.unit_price }],
});
});
});
}
// ------------------------------------------------------------------
@ -3789,8 +3824,9 @@ window.Page_admin = (() => {
});
}
function _openNeueRechnungModal(reload) {
function _openNeueRechnungModal(reload, prefill = null) {
const id = `inv-new-${Date.now()}`;
const p = prefill || {};
UI.modal.open({
title: `${UI.icon('receipt')} Neue Rechnung erstellen`,
@ -3801,25 +3837,32 @@ window.Page_admin = (() => {
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div>
<label class="form-label" style="font-size:var(--text-xs)">Empfänger Name *</label>
<input class="form-control" name="recipient_name" type="text" required placeholder="Max Muster">
<input class="form-control" name="recipient_name" type="text" required
placeholder="Max Muster" value="${_esc(p.recipient_name || '')}">
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">E-Mail</label>
<input class="form-control" name="recipient_email" type="email" placeholder="max@example.com">
<input class="form-control" name="recipient_email" type="email"
placeholder="max@example.com" value="${_esc(p.recipient_email || '')}">
</div>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Adresse <span style="color:var(--c-text-muted)">(optional)</span></label>
<label class="form-label" style="font-size:var(--text-xs)">Adresse
${p.recipient_name && !p.recipient_address
? `<span style="color:var(--c-warning);font-size:10px"> ⚠ Nutzer hat keine Rechnungsadresse hinterlegt</span>`
: '<span style="color:var(--c-text-muted)">(optional)</span>'}
</label>
<textarea class="form-control" name="recipient_address" rows="2"
placeholder="Musterstr. 1&#10;12345 Berlin"
style="resize:vertical;font-family:inherit"></textarea>
style="resize:vertical;font-family:inherit">${_esc(p.recipient_address || '')}</textarea>
</div>
<div>
<label class="form-label" style="font-size:var(--text-xs)">Leistungszeitraum <span style="color:var(--c-text-muted)">(optional)</span></label>
<input class="form-control" name="service_period" type="text"
placeholder="01.01.2026 31.12.2026">
placeholder="01.01.2026 31.12.2026"
value="${_esc(p.service_period || '')}">
</div>
<!-- Positionen -->
@ -3915,8 +3958,12 @@ window.Page_admin = (() => {
`;
}
// Erste Position hinzufügen
_addItem('Ban Yaro Pro Jahresabo', 1, 29.00);
// Erste Position — aus Prefill oder Standard
if (p.items && p.items.length) {
p.items.forEach(it => _addItem(it.description, it.quantity ?? 1, it.unit_price ?? 0));
} else {
_addItem('Ban Yaro Pro Jahresabo', 1, 29.00);
}
// Weitere Position
document.getElementById(`${id}-add-item`)?.addEventListener('click', () => _addItem());

View file

@ -307,6 +307,12 @@ window.Page_settings = (() => {
Wir schalten deinen Account manuell frei innerhalb von 24 Stunden.
Wir melden uns mit den Zahlungsdetails per E-Mail.
</div>
${!_appState.user?.billing_address ? `
<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:#fff8f0;border:1px solid #f0a060;
font-size:var(--text-xs);color:#c05000;line-height:1.6;margin-top:var(--space-2)">
💡 Tipp: Trag deine <strong>Rechnungsadresse</strong> im Profil ein dann können wir die Rechnung vollständig ausstellen.
</div>` : ''}
${breederForm}
</div>`,
footer: `
@ -1135,6 +1141,13 @@ window.Page_settings = (() => {
value="${_esc(u.social_link || '')}"
style="${inputStyle}">
</div>
<div style="border-top:1px solid var(--c-border);padding-top:var(--space-3);margin-top:var(--space-1)">
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:2px">Rechnungsadresse</label>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">Wird auf Rechnungen gedruckt. Straße in Zeile 1, PLZ + Ort in Zeile 2.</div>
<textarea name="billing_address" rows="2" maxlength="200"
placeholder="Musterstraße 1&#10;12345 Berlin"
style="${inputStyle};resize:vertical;font-family:inherit">${_esc(u.billing_address || '')}</textarea>
</div>
<div>
<label style="display:block;font-size:var(--text-sm);font-weight:600;margin-bottom:var(--space-1)">Profil-Sichtbarkeit</label>
<select name="profil_sichtbarkeit" style="${inputStyle}">${sichtbarkeitOpts}</select>
@ -1161,6 +1174,7 @@ window.Page_settings = (() => {
erfahrung: fd.erfahrung || '',
social_link: fd.social_link || '',
profil_sichtbarkeit: fd.profil_sichtbarkeit || 'public',
billing_address: fd.billing_address || '',
});
Object.assign(_appState.user, updated);
UI.modal.close?.();

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache
============================================================ */
const CACHE_VERSION = 'by-v966';
const CACHE_VERSION = 'by-v967';
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