Feat: Referral/Gründer-Rabatt in Rechnungssystem integriert (Discount-Badge, Auto-Vorausfüllung)
This commit is contained in:
commit
63a1fc585c
3 changed files with 132 additions and 11 deletions
|
|
@ -1137,13 +1137,28 @@ async def list_upgrade_requests(user=Depends(require_admin)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
rows = conn.execute("""
|
rows = conn.execute("""
|
||||||
SELECT r.id, r.user_id, r.tier, r.message, r.created_at, r.fulfilled_at,
|
SELECT r.id, r.user_id, r.tier, r.message, r.created_at, r.fulfilled_at,
|
||||||
u.name, u.email, u.billing_address
|
u.name, u.email, u.billing_address,
|
||||||
|
u.is_founder, u.is_founder_pending, u.referred_by,
|
||||||
|
COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=u.id), 0) AS referral_count
|
||||||
FROM upgrade_requests r
|
FROM upgrade_requests r
|
||||||
JOIN users u ON u.id = r.user_id
|
JOIN users u ON u.id = r.user_id
|
||||||
ORDER BY r.fulfilled_at IS NOT NULL, r.created_at DESC
|
ORDER BY r.fulfilled_at IS NOT NULL, r.created_at DESC
|
||||||
LIMIT 100
|
LIMIT 100
|
||||||
""").fetchall()
|
""").fetchall()
|
||||||
return [dict(r) for r in rows]
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
d_info = _get_discount_info(conn, r["user_id"])
|
||||||
|
d["discount_pct"] = d_info["discount_pct"]
|
||||||
|
d["discount_reason"] = d_info["reason"]
|
||||||
|
result.append(d)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}/discount")
|
||||||
|
def get_user_discount(user_id: int, admin=Depends(require_admin)):
|
||||||
|
with db() as conn:
|
||||||
|
return _get_discount_info(conn, user_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upgrade-requests/{req_id}/fulfill")
|
@router.post("/upgrade-requests/{req_id}/fulfill")
|
||||||
|
|
@ -1268,6 +1283,35 @@ async def fulfill_upgrade_request(req_id: int, user=Depends(require_admin)):
|
||||||
return {"ok": True, "tier": req["tier"], "user": req["name"]}
|
return {"ok": True, "tier": req["tier"], "user": req["name"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_discount_info(conn, user_id: int) -> dict:
|
||||||
|
"""Berechnet Rabatt für einen User basierend auf Gründer-Status und Referrals."""
|
||||||
|
row = conn.execute(
|
||||||
|
"""SELECT u.is_founder, u.is_founder_pending, u.referred_by,
|
||||||
|
COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=u.id), 0) AS referral_count
|
||||||
|
FROM users u WHERE u.id=?""",
|
||||||
|
(user_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return {"discount_pct": 0, "reason": None, "referral_count": 0}
|
||||||
|
|
||||||
|
if row["is_founder"] or row["is_founder_pending"]:
|
||||||
|
return {"discount_pct": 100, "reason": "founder", "referral_count": row["referral_count"]}
|
||||||
|
|
||||||
|
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,)
|
||||||
|
).fetchone()
|
||||||
|
if referrer and (referrer["is_founder"] or referrer["is_founder_pending"]):
|
||||||
|
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)]:
|
||||||
|
if count >= threshold:
|
||||||
|
return {"discount_pct": pct, "reason": "referral", "referral_count": count}
|
||||||
|
return {"discount_pct": 0, "reason": None, "referral_count": count}
|
||||||
|
|
||||||
|
|
||||||
async def _handle_upgrade_invoices(req: dict, new_tier_label: str):
|
async def _handle_upgrade_invoices(req: dict, new_tier_label: str):
|
||||||
"""Storniert offene Rechnungen des alten Tiers und legt neuen Entwurf an."""
|
"""Storniert offene Rechnungen des alten Tiers und legt neuen Entwurf an."""
|
||||||
from routes.invoices import _next_invoice_number
|
from routes.invoices import _next_invoice_number
|
||||||
|
|
@ -1302,17 +1346,31 @@ async def _handle_upgrade_invoices(req: dict, new_tier_label: str):
|
||||||
).fetchone()
|
).fetchone()
|
||||||
billing_address = billing["billing_address"] if billing else None
|
billing_address = billing["billing_address"] if billing else None
|
||||||
|
|
||||||
|
disc_info = _get_discount_info(conn, req["user_id"])
|
||||||
|
discount_pct = disc_info["discount_pct"]
|
||||||
|
discount_amt = round(price * discount_pct / 100, 2)
|
||||||
|
after_disc = round(price - discount_amt, 2)
|
||||||
|
|
||||||
|
_AGB = "Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen."
|
||||||
|
if disc_info["reason"] == "founder":
|
||||||
|
note = f"Gründer-Sonderkonditionen: {new_tier_label} kostenfrei als Dankeschön für deine Unterstützung als Gründer! {_AGB}"
|
||||||
|
elif disc_info["reason"] == "referred_by_founder":
|
||||||
|
note = f"Willkommen in der Gründer-Community! Als persönlich von einem Gründer eingeladenes Mitglied erhältst du dauerhaft {discount_pct}% Rabatt. {_AGB}"
|
||||||
|
elif disc_info["reason"] == "referral":
|
||||||
|
note = f"Herzlichen Dank für deine Unterstützung! Für {disc_info['referral_count']} geworbene Freunde erhältst du {discount_pct}% Rabatt. {_AGB}"
|
||||||
|
else:
|
||||||
|
note = f"{_AGB} (Upgrade von {req.get('old_tier','Standard')} auf {new_tier_label})"
|
||||||
|
|
||||||
inv_number = _next_invoice_number(conn)
|
inv_number = _next_invoice_number(conn)
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT INTO invoices
|
INSERT INTO invoices
|
||||||
(invoice_number, user_id, recipient_name, recipient_email, recipient_address,
|
(invoice_number, user_id, recipient_name, recipient_email, recipient_address,
|
||||||
description, service_period, amount_net, discount_pct, discount_amount,
|
description, service_period, amount_net, discount_pct, discount_amount,
|
||||||
amount_after_discount, tax_rate, tax_amount, amount_gross, notes)
|
amount_after_discount, tax_rate, tax_amount, amount_gross, notes)
|
||||||
VALUES (?,?,?,?,?,?,?,?,0,0,?,0,0,?,?)
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,0,0,?,?)
|
||||||
""", (
|
""", (
|
||||||
inv_number, req["user_id"], req["name"], req["email"], billing_address,
|
inv_number, req["user_id"], req["name"], req["email"], billing_address,
|
||||||
description, period, price, price, price,
|
description, period, price, discount_pct, discount_amt, after_disc, after_disc, note,
|
||||||
f"Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen. (Upgrade von {req.get('old_tier','Standard')} auf {new_tier_label})",
|
|
||||||
))
|
))
|
||||||
invoice_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
invoice_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,49 @@ async def _create_renewal_invoice_draft(user: dict, expires: date, tier_label: s
|
||||||
).fetchone()
|
).fetchone()
|
||||||
billing_address = row["billing_address"] if row else None
|
billing_address = row["billing_address"] if row else None
|
||||||
|
|
||||||
|
# Rabatt berechnen (inline, da kein Admin-Import möglich)
|
||||||
|
disc_row = conn.execute(
|
||||||
|
"""SELECT u.is_founder, u.is_founder_pending, u.referred_by,
|
||||||
|
COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=u.id), 0) AS referral_count
|
||||||
|
FROM users u WHERE u.id=?""",
|
||||||
|
(user["id"],)
|
||||||
|
).fetchone()
|
||||||
|
discount_pct = 0
|
||||||
|
discount_reason = None
|
||||||
|
referral_count = 0
|
||||||
|
if disc_row:
|
||||||
|
referral_count = disc_row["referral_count"]
|
||||||
|
if disc_row["is_founder"] or disc_row["is_founder_pending"]:
|
||||||
|
discount_pct = 100
|
||||||
|
discount_reason = "founder"
|
||||||
|
elif (disc_row["referred_by"] or 0) > 0:
|
||||||
|
ref = conn.execute(
|
||||||
|
"SELECT is_founder, is_founder_pending 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"
|
||||||
|
if not discount_reason:
|
||||||
|
for thr, pct in [(50, 50), (20, 30), (10, 20)]:
|
||||||
|
if referral_count >= thr:
|
||||||
|
discount_pct = pct
|
||||||
|
discount_reason = "referral"
|
||||||
|
break
|
||||||
|
|
||||||
|
discount_amt = round(price * discount_pct / 100, 2)
|
||||||
|
after_disc = round(price - discount_amt, 2)
|
||||||
|
|
||||||
|
_AGB = "Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen."
|
||||||
|
if discount_reason == "founder":
|
||||||
|
notes = f"Gründer-Sonderkonditionen: {tier_label} kostenfrei als Dankeschön für deine Unterstützung als Gründer! {_AGB} (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})"
|
||||||
|
elif discount_reason == "referred_by_founder":
|
||||||
|
notes = f"Willkommen in der Gründer-Community! Als persönlich von einem Gründer eingeladenes Mitglied erhältst du dauerhaft {discount_pct}% Rabatt. {_AGB} (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})"
|
||||||
|
elif discount_reason == "referral":
|
||||||
|
notes = f"Herzlichen Dank für deine Unterstützung! Für {referral_count} geworbene Freunde erhältst du {discount_pct}% Rabatt. {_AGB} (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})"
|
||||||
|
else:
|
||||||
|
notes = f"{_AGB} (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})"
|
||||||
|
|
||||||
invoice_number = _next_invoice_number(conn)
|
invoice_number = _next_invoice_number(conn)
|
||||||
description = f"{tier_label} Jahresabo (Verlängerung)"
|
description = f"{tier_label} Jahresabo (Verlängerung)"
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
|
|
@ -253,12 +296,11 @@ async def _create_renewal_invoice_draft(user: dict, expires: date, tier_label: s
|
||||||
(invoice_number, user_id, recipient_name, recipient_email, recipient_address,
|
(invoice_number, user_id, recipient_name, recipient_email, recipient_address,
|
||||||
description, service_period, amount_net, discount_pct, discount_amount,
|
description, service_period, amount_net, discount_pct, discount_amount,
|
||||||
amount_after_discount, tax_rate, tax_amount, amount_gross, notes)
|
amount_after_discount, tax_rate, tax_amount, amount_gross, notes)
|
||||||
VALUES (?,?,?,?,?,?,?,?,0,0,?,0,0,?,?)
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,0,0,?,?)
|
||||||
""", (
|
""", (
|
||||||
invoice_number, user["id"], user["name"], user["email"], billing_address,
|
invoice_number, user["id"], user["name"], user["email"], billing_address,
|
||||||
description, period,
|
description, period,
|
||||||
price, price, price,
|
price, discount_pct, discount_amt, after_disc, after_disc, notes,
|
||||||
f"Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen. (Automatisch erstellt, Ablauf: {expires.strftime('%d.%m.%Y')})",
|
|
||||||
))
|
))
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,1,?,?)",
|
"INSERT INTO invoice_items (invoice_id, description, quantity, unit_price, total) VALUES (?,?,1,?,?)",
|
||||||
|
|
|
||||||
|
|
@ -3531,8 +3531,14 @@ window.Page_admin = (() => {
|
||||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">${_esc(r.email)}</div>
|
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-2)">${_esc(r.email)}</div>
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
|
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
|
||||||
${tierBadge(r.tier)}
|
${tierBadge(r.tier)}
|
||||||
|
${r.discount_pct > 0 ? `<span style="display:inline-block;padding:1px 8px;border-radius:999px;
|
||||||
|
font-size:11px;font-weight:700;background:#e67e22;color:#fff;margin-left:4px">
|
||||||
|
${r.discount_pct}% Rabatt</span>` : ''}
|
||||||
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${r.created_at?.slice(0,10) || ''}</span>
|
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${r.created_at?.slice(0,10) || ''}</span>
|
||||||
</div>
|
</div>
|
||||||
|
${r.discount_reason === 'founder' ? `<div style="font-size:10px;color:#e67e22;margin-top:2px">Gründer — kostenfrei</div>` : ''}
|
||||||
|
${r.discount_reason === 'referred_by_founder' ? `<div style="font-size:10px;color:#e67e22;margin-top:2px">Von Gründer eingeladen</div>` : ''}
|
||||||
|
${r.discount_reason === 'referral' ? `<div style="font-size:10px;color:var(--c-text-muted);margin-top:2px">${r.referral_count} Freunde geworben</div>` : ''}
|
||||||
${r.message ? `<div style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-secondary);
|
${r.message ? `<div style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-secondary);
|
||||||
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||||||
background:var(--c-surface-raised,rgba(0,0,0,.04))">
|
background:var(--c-surface-raised,rgba(0,0,0,.04))">
|
||||||
|
|
@ -3544,6 +3550,9 @@ window.Page_admin = (() => {
|
||||||
<button class="btn adm-invoice-btn"
|
<button class="btn adm-invoice-btn"
|
||||||
data-name="${_esc(r.name)}" data-email="${_esc(r.email)}"
|
data-name="${_esc(r.name)}" data-email="${_esc(r.email)}"
|
||||||
data-tier="${r.tier}" data-address="${_esc(r.billing_address || '')}"
|
data-tier="${r.tier}" data-address="${_esc(r.billing_address || '')}"
|
||||||
|
data-discount="${r.discount_pct || 0}"
|
||||||
|
data-discount-reason="${r.discount_reason || ''}"
|
||||||
|
data-referral-count="${r.referral_count || 0}"
|
||||||
style="background:#e67e22;color:#fff;border:none;
|
style="background:#e67e22;color:#fff;border:none;
|
||||||
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
|
||||||
cursor:pointer;font-size:var(--text-sm);font-weight:600">
|
cursor:pointer;font-size:var(--text-sm);font-weight:600">
|
||||||
|
|
@ -3632,9 +3641,20 @@ window.Page_admin = (() => {
|
||||||
const _fmt = d => `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}.${d.getFullYear()}`;
|
const _fmt = d => `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}.${d.getFullYear()}`;
|
||||||
const _period = `${_fmt(_now)} – ${_fmt(_end)}`;
|
const _period = `${_fmt(_now)} – ${_fmt(_end)}`;
|
||||||
|
|
||||||
|
function _discountNote(reason, count, pct, tierLabel) {
|
||||||
|
const agb = 'Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen.';
|
||||||
|
if (reason === 'founder') return `Gründer-Sonderkonditionen: ${tierLabel} kostenfrei als Dankeschön für deine Unterstützung als Gründer! ${agb}`;
|
||||||
|
if (reason === 'referred_by_founder') return `Willkommen in der Gründer-Community! Als persönlich von einem Gründer eingeladenes Mitglied erhältst du dauerhaft ${pct}% Rabatt. ${agb}`;
|
||||||
|
if (reason === 'referral') return `Herzlichen Dank für deine Unterstützung! Für ${count} geworbene Freunde erhältst du ${pct}% Rabatt. ${agb}`;
|
||||||
|
return agb;
|
||||||
|
}
|
||||||
|
|
||||||
el.querySelectorAll('.adm-invoice-btn').forEach(btn => {
|
el.querySelectorAll('.adm-invoice-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const { name, email, tier, address } = btn.dataset;
|
const { name, email, tier, address } = btn.dataset;
|
||||||
|
const discountPct = Number(btn.dataset.discount) || 0;
|
||||||
|
const discountReason = btn.dataset.discountReason || '';
|
||||||
|
const referralCount = Number(btn.dataset.referralCount) || 0;
|
||||||
const tierItem = TIER_ITEMS[tier] || { description: 'Ban Yaro Abo', unit_price: 0 };
|
const tierItem = TIER_ITEMS[tier] || { description: 'Ban Yaro Abo', unit_price: 0 };
|
||||||
_openNeueRechnungModal(() => {
|
_openNeueRechnungModal(() => {
|
||||||
_tab = 'rechnungen';
|
_tab = 'rechnungen';
|
||||||
|
|
@ -3644,8 +3664,9 @@ window.Page_admin = (() => {
|
||||||
recipient_email: email,
|
recipient_email: email,
|
||||||
recipient_address: address || '',
|
recipient_address: address || '',
|
||||||
service_period: _period,
|
service_period: _period,
|
||||||
|
discount_pct: discountPct,
|
||||||
|
notes: _discountNote(discountReason, referralCount, discountPct, tierItem.description),
|
||||||
items: [{ description: tierItem.description, quantity: 1, unit_price: tierItem.unit_price }],
|
items: [{ description: tierItem.description, quantity: 1, unit_price: tierItem.unit_price }],
|
||||||
notes: 'Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen.',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -3913,7 +3934,7 @@ window.Page_admin = (() => {
|
||||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:var(--space-3);align-items:center">
|
<div style="display:grid;grid-template-columns:auto 1fr;gap:var(--space-3);align-items:center">
|
||||||
<div style="display:flex;align-items:center;gap:var(--space-2)">
|
<div style="display:flex;align-items:center;gap:var(--space-2)">
|
||||||
<label class="form-label" style="font-size:var(--text-xs);margin:0;white-space:nowrap">Rabatt %</label>
|
<label class="form-label" style="font-size:var(--text-xs);margin:0;white-space:nowrap">Rabatt %</label>
|
||||||
<input class="form-control" name="discount_pct" type="number" min="0" max="100" value="0"
|
<input class="form-control" name="discount_pct" type="number" min="0" max="100" value="${p.discount_pct ?? 0}"
|
||||||
style="width:80px" id="${id}-discount">
|
style="width:80px" id="${id}-discount">
|
||||||
</div>
|
</div>
|
||||||
<!-- Live-Vorschau -->
|
<!-- Live-Vorschau -->
|
||||||
|
|
@ -3927,7 +3948,7 @@ window.Page_admin = (() => {
|
||||||
<label class="form-label" style="font-size:var(--text-xs)">Notizen <span style="color:var(--c-text-muted)">(optional)</span></label>
|
<label class="form-label" style="font-size:var(--text-xs)">Notizen <span style="color:var(--c-text-muted)">(optional)</span></label>
|
||||||
<textarea class="form-control" name="notes" rows="2"
|
<textarea class="form-control" name="notes" rows="2"
|
||||||
style="resize:vertical;font-family:inherit"
|
style="resize:vertical;font-family:inherit"
|
||||||
placeholder="Interne Notiz / Zahlungshinweis"></textarea>
|
placeholder="Interne Notiz / Zahlungshinweis">${_esc(p.notes || '')}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue