diff --git a/requirements.txt b/requirements.txt
index 74eb518..a706cd7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -29,6 +29,9 @@ textstat>=0.7.12
scikit-learn==1.5.2
setuptools>=65.0.0
+# PDF Generation
+reportlab>=4.0.0
+
# Web Frontend
fastapi==0.115.0
uvicorn==0.32.0
diff --git a/src/agents/base.py b/src/agents/base.py
index c00c8cb..7e9dbb1 100644
--- a/src/agents/base.py
+++ b/src/agents/base.py
@@ -75,6 +75,14 @@ class BaseAgent(ABC):
except Exception as e:
logger.warning(f"Failed to log usage to DB: {e}")
+ # Increment company token quota
+ if self._company_id:
+ try:
+ from uuid import UUID
+ await db.increment_company_tokens(UUID(self._company_id), total_tokens)
+ except Exception as e:
+ logger.warning(f"Failed to increment company tokens: {e}")
+
async def call_openai(
self,
system_prompt: str,
diff --git a/src/database/client.py b/src/database/client.py
index 31e7dee..15d0a84 100644
--- a/src/database/client.py
+++ b/src/database/client.py
@@ -1184,18 +1184,17 @@ class DatabaseClient:
self,
key: str,
max_employees: int,
- max_posts_per_day: int,
- max_researches_per_day: int,
+ daily_token_limit: Optional[int] = None,
description: Optional[str] = None
) -> LicenseKey:
"""Create new license key."""
data = {
"key": key,
"max_employees": max_employees,
- "max_posts_per_day": max_posts_per_day,
- "max_researches_per_day": max_researches_per_day,
"description": description,
}
+ if daily_token_limit is not None:
+ data["daily_token_limit"] = daily_token_limit
result = await asyncio.to_thread(
lambda: self.client.table("license_keys").insert(data).execute()
)
@@ -1222,6 +1221,16 @@ class DatabaseClient:
logger.info(f"Marked license key as used: {key}")
return LicenseKey(**result.data[0])
+ async def get_license_key_by_id(self, key_id: UUID) -> Optional[LicenseKey]:
+ """Get license key by UUID."""
+ result = await asyncio.to_thread(
+ lambda: self.client.table("license_keys")
+ .select("*")
+ .eq("id", str(key_id))
+ .execute()
+ )
+ return LicenseKey(**result.data[0]) if result.data else None
+
async def update_license_key(self, key_id: UUID, updates: Dict[str, Any]) -> LicenseKey:
"""Update license key limits (admin only)."""
result = await asyncio.to_thread(
@@ -1270,8 +1279,7 @@ class DatabaseClient:
data = {
"company_id": str(company_id),
"date": date_.isoformat(),
- "posts_created": 0,
- "researches_created": 0
+ "tokens_used": 0
}
result = await asyncio.to_thread(
lambda: self.client.table("company_daily_quotas")
@@ -1280,47 +1288,22 @@ class DatabaseClient:
)
return CompanyDailyQuota(**result.data[0])
- async def increment_company_posts_quota(self, company_id: UUID) -> None:
- """Increment daily posts count for company."""
+ async def increment_company_tokens(self, company_id: UUID, tokens: int) -> None:
+ """Increment daily token usage for company."""
try:
quota = await self.get_company_daily_quota(company_id)
- new_count = quota.posts_created + 1
+ new_count = quota.tokens_used + tokens
- result = await asyncio.to_thread(
+ await asyncio.to_thread(
lambda: self.client.table("company_daily_quotas")
- .update({"posts_created": new_count})
+ .update({"tokens_used": new_count})
.eq("id", str(quota.id))
.execute()
)
- logger.info(f"Incremented posts quota for company {company_id}: {quota.posts_created} -> {new_count}")
-
- if not result.data:
- logger.error(f"Failed to increment posts quota - no data returned")
+ logger.info(f"Incremented token quota for company {company_id}: {quota.tokens_used} -> {new_count}")
except Exception as e:
- logger.error(f"Error incrementing posts quota for company {company_id}: {e}")
- raise
-
- async def increment_company_researches_quota(self, company_id: UUID) -> None:
- """Increment daily researches count for company."""
- try:
- quota = await self.get_company_daily_quota(company_id)
- new_count = quota.researches_created + 1
-
- result = await asyncio.to_thread(
- lambda: self.client.table("company_daily_quotas")
- .update({"researches_created": new_count})
- .eq("id", str(quota.id))
- .execute()
- )
-
- logger.info(f"Incremented researches quota for company {company_id}: {quota.researches_created} -> {new_count}")
-
- if not result.data:
- logger.error(f"Failed to increment researches quota - no data returned")
- except Exception as e:
- logger.error(f"Error incrementing researches quota for company {company_id}: {e}")
- raise
+ logger.warning(f"Error incrementing token quota for company {company_id}: {e}")
async def get_company_limits(self, company_id: UUID) -> Optional[LicenseKey]:
"""Get company limits from associated license key.
@@ -1340,39 +1323,21 @@ class DatabaseClient:
return LicenseKey(**result.data[0]) if result.data else None
- async def check_company_post_limit(self, company_id: UUID) -> tuple[bool, str]:
- """Check if company can create more posts today.
+ async def check_company_token_limit(self, company_id: UUID) -> tuple[bool, str, int, int]:
+ """Check if company has token budget remaining today.
- Returns (can_create: bool, error_message: str)
+ Returns (can_proceed: bool, error_message: str, tokens_used: int, daily_limit: int)
"""
license_key = await self.get_company_limits(company_id)
- if not license_key:
- # No license key, use defaults (unlimited)
- return True, ""
+ if not license_key or license_key.daily_token_limit is None:
+ return True, "", 0, 0 # no limit = unlimited
quota = await self.get_company_daily_quota(company_id)
- if quota.posts_created >= license_key.max_posts_per_day:
- return False, f"Tageslimit erreicht ({license_key.max_posts_per_day} Posts/Tag). Versuche es morgen wieder."
+ if quota.tokens_used >= license_key.daily_token_limit:
+ return False, f"Tageslimit erreicht ({license_key.daily_token_limit:,} Tokens/Tag). Morgen wieder verfügbar.", quota.tokens_used, license_key.daily_token_limit
- return True, ""
-
- async def check_company_research_limit(self, company_id: UUID) -> tuple[bool, str]:
- """Check if company can create more researches today.
-
- Returns (can_create: bool, error_message: str)
- """
- license_key = await self.get_company_limits(company_id)
- if not license_key:
- # No license key, use defaults (unlimited)
- return True, ""
-
- quota = await self.get_company_daily_quota(company_id)
-
- if quota.researches_created >= license_key.max_researches_per_day:
- return False, f"Tageslimit erreicht ({license_key.max_researches_per_day} Researches/Tag). Versuche es morgen wieder."
-
- return True, ""
+ return True, "", quota.tokens_used, license_key.daily_token_limit
async def check_company_employee_limit(self, company_id: UUID) -> tuple[bool, str]:
"""Check if company can add more employees.
diff --git a/src/database/models.py b/src/database/models.py
index 013eaf5..98e2fd1 100644
--- a/src/database/models.py
+++ b/src/database/models.py
@@ -365,8 +365,7 @@ class LicenseKey(DBModel):
# Limits
max_employees: int = 5
- max_posts_per_day: int = 10
- max_researches_per_day: int = 5
+ daily_token_limit: Optional[int] = None # None = unlimited
# Usage
used: bool = False
@@ -382,7 +381,6 @@ class CompanyDailyQuota(DBModel):
id: Optional[UUID] = None
company_id: UUID
date: date
- posts_created: int = 0
- researches_created: int = 0
+ tokens_used: int = 0
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
diff --git a/src/services/email_service.py b/src/services/email_service.py
index b7dd928..3f9d505 100644
--- a/src/services/email_service.py
+++ b/src/services/email_service.py
@@ -3,6 +3,8 @@ import smtplib
import secrets
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
+from email.mime.base import MIMEBase
+from email import encoders
from typing import Optional, Dict, Any
from datetime import datetime, timedelta
from uuid import UUID
@@ -75,6 +77,48 @@ def send_email(to_email: str, subject: str, html_content: str) -> bool:
return False
+def send_email_with_attachment(
+ to_email: str,
+ subject: str,
+ html_content: str,
+ attachment_bytes: bytes,
+ attachment_filename: str,
+) -> bool:
+ """Send an email with a file attachment (e.g. PDF)."""
+ if not settings.smtp_host or not settings.smtp_user:
+ logger.warning("SMTP not configured, skipping email send")
+ return False
+
+ try:
+ msg = MIMEMultipart("mixed")
+ msg["Subject"] = subject
+ msg["From"] = f"{settings.smtp_from_name} <{settings.smtp_user}>"
+ msg["To"] = to_email
+
+ # HTML body
+ alt_part = MIMEMultipart("alternative")
+ alt_part.attach(MIMEText(html_content, "html"))
+ msg.attach(alt_part)
+
+ # Attachment
+ part = MIMEBase("application", "octet-stream")
+ part.set_payload(attachment_bytes)
+ encoders.encode_base64(part)
+ part.add_header("Content-Disposition", f'attachment; filename="{attachment_filename}"')
+ msg.attach(part)
+
+ with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server:
+ server.starttls()
+ server.login(settings.smtp_user, settings.smtp_password)
+ server.sendmail(settings.smtp_user, to_email, msg.as_string())
+
+ logger.info(f"Email with attachment sent to {to_email}: {subject}")
+ return True
+ except Exception as e:
+ logger.error(f"Failed to send email with attachment to {to_email}: {e}")
+ return False
+
+
def send_approval_request_email(
to_email: str,
post_id: UUID,
diff --git a/src/web/admin/routes.py b/src/web/admin/routes.py
index e27cf27..f5a4b47 100644
--- a/src/web/admin/routes.py
+++ b/src/web/admin/routes.py
@@ -1,7 +1,9 @@
"""Admin panel routes (password-protected) - User Management & Statistics."""
import re
import secrets
+import math
from datetime import datetime, timedelta
+from io import BytesIO
from pathlib import Path
from typing import Optional
from uuid import UUID
@@ -12,11 +14,19 @@ from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from loguru import logger
+from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable, Image
+from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
+from reportlab.lib.units import cm
+from reportlab.lib import colors
+from reportlab.lib.pagesizes import A4
+
from src.database import db
from src.web.admin.auth import (
WEB_PASSWORD, AUTH_COOKIE_NAME, hash_password, verify_auth
)
from src.web.user.auth import UserSession, set_user_session
+from src.services.email_service import send_email_with_attachment
+from src.config import settings
# Router with /admin prefix
admin_router = APIRouter(prefix="/admin", tags=["admin"])
@@ -549,8 +559,7 @@ async def license_keys_page(request: Request):
async def generate_license_key(
request: Request,
max_employees: int = Form(...),
- max_posts_per_day: int = Form(...),
- max_researches_per_day: int = Form(...),
+ daily_token_limit: Optional[int] = Form(None),
description: str = Form(None)
):
"""Generate new license key."""
@@ -567,8 +576,7 @@ async def generate_license_key(
license_key = await db.create_license_key(
key=key,
max_employees=max_employees,
- max_posts_per_day=max_posts_per_day,
- max_researches_per_day=max_researches_per_day,
+ daily_token_limit=daily_token_limit,
description=description
)
@@ -583,8 +591,7 @@ async def update_license_key_route(
request: Request,
key_id: str,
max_employees: int = Form(...),
- max_posts_per_day: int = Form(...),
- max_researches_per_day: int = Form(...),
+ daily_token_limit: Optional[int] = Form(None),
description: str = Form(None)
):
"""Update license key limits."""
@@ -594,8 +601,7 @@ async def update_license_key_route(
try:
updates = {
"max_employees": max_employees,
- "max_posts_per_day": max_posts_per_day,
- "max_researches_per_day": max_researches_per_day,
+ "daily_token_limit": daily_token_limit,
"description": description
}
@@ -618,3 +624,333 @@ async def delete_license_key_route(request: Request, key_id: str):
except Exception as e:
logger.error(f"Error deleting license key: {e}")
raise HTTPException(status_code=500, detail=str(e))
+
+
+# ==================== LICENSE KEY OFFER ====================
+
+AVG_TOKENS_PER_POST = 50_000
+SERVER_COST_EUR = 16.0
+SERVER_SHARE_FRACTION = 0.10 # 10% of server costs per customer
+API_COST_PER_1K_TOKENS_EUR = 0.003 # rough average API cost in EUR
+
+
+def _fmt_eur(amount: float) -> str:
+ """Format a float as German EUR string, e.g. 1.250,00 €"""
+ return f"{amount:,.2f} €".replace(",", "X").replace(".", ",").replace("X", ".")
+
+
+def _build_offer_pdf(
+ key_description: str,
+ max_employees: int,
+ daily_token_limit: Optional[int],
+ price: float,
+ payment_frequency: str,
+ recipient_email: str,
+) -> bytes:
+ """Generate a professional, simple PDF offer with logo."""
+ buffer = BytesIO()
+
+ PAGE_W, PAGE_H = A4
+ MARGIN = 1.8 * cm
+
+ doc = SimpleDocTemplate(
+ buffer,
+ pagesize=A4,
+ rightMargin=MARGIN,
+ leftMargin=MARGIN,
+ topMargin=MARGIN,
+ bottomMargin=MARGIN,
+ )
+
+ # ── Colors ────────────────────────────────────────────────────────────────
+ C_DARK = colors.HexColor("#2d3838")
+ C_HIGHLIGHT = colors.HexColor("#ffc700")
+ C_GRAY = colors.HexColor("#666666")
+ C_LGRAY = colors.HexColor("#f5f5f5")
+ C_RULE = colors.HexColor("#e0e0e0")
+ C_WHITE = colors.white
+ CONTENT_W = PAGE_W - 2 * MARGIN
+ HALF_W = (CONTENT_W - 0.3 * cm) / 2 # for 2-col feature grid
+
+ # ── Styles ────────────────────────────────────────────────────────────────
+ s = getSampleStyleSheet()
+ def ps(name, **kw):
+ return ParagraphStyle(name, parent=s["Normal"], **kw)
+
+ s_body = ps("body", fontSize=9.5, textColor=C_DARK, leading=14)
+ s_small = ps("sml", fontSize=8, textColor=C_GRAY, leading=12)
+ s_label = ps("lbl", fontSize=8.5, textColor=C_GRAY, leading=13)
+ s_value = ps("val", fontSize=9.5, textColor=C_DARK, leading=13, fontName="Helvetica-Bold")
+ s_sec = ps("sec", fontSize=9, textColor=C_GRAY, leading=12, fontName="Helvetica-Bold",
+ spaceBefore=0, spaceAfter=0, letterSpacing=0.8)
+ s_price = ps("prc", fontSize=19, textColor=C_DARK, leading=24, fontName="Helvetica-Bold")
+ s_feat = ps("feat", fontSize=8.5, textColor=C_DARK, leading=12, fontName="Helvetica-Bold")
+ s_fdesc = ps("fdsc", fontSize=7.5, textColor=C_GRAY, leading=11)
+
+ # ── Computed values ───────────────────────────────────────────────────────
+ today = datetime.utcnow().strftime("%d.%m.%Y")
+ validity = (datetime.utcnow() + timedelta(days=30)).strftime("%d.%m.%Y")
+ offer_number = f"ANG-{datetime.utcnow().strftime('%Y%m%d-%H%M')}"
+ plan_name = key_description or "Standard Plan"
+ price_str = _fmt_eur(price)
+ freq_cap = payment_frequency.capitalize()
+
+ if daily_token_limit:
+ posts_per_month = int(daily_token_limit * 30 / AVG_TOKENS_PER_POST)
+ token_str = (
+ f"{daily_token_limit:,}".replace(",", ".") +
+ f" Tokens / Tag (ca. {posts_per_month} Posts / Monat)"
+ )
+ else:
+ token_str = "Unbegrenzt"
+
+ story = []
+
+ # ── Header: Logo + ANGEBOT ────────────────────────────────────────────────
+ logo_path = Path(__file__).parent.parent / "static" / "logo.png"
+ logo = (
+ Image(str(logo_path), width=2.8 * cm, height=2.8 * cm, kind="proportional")
+ if logo_path.exists()
+ else Paragraph("Onyva", ps("lt", fontSize=13, textColor=C_WHITE, fontName="Helvetica-Bold"))
+ )
+ hdr = Table(
+ [[
+ logo,
+ [
+ Paragraph('ANGEBOT',
+ ps("ttl", fontSize=20, fontName="Helvetica-Bold", alignment=2, leading=25)),
+ Spacer(1, 0.1 * cm),
+ Paragraph(f'{offer_number}',
+ ps("anr", fontSize=8, textColor=C_GRAY, alignment=2, leading=12)),
+ ],
+ ]],
+ colWidths=[4.5 * cm, CONTENT_W - 4.5 * cm],
+ )
+ hdr.setStyle(TableStyle([
+ ("BACKGROUND", (0, 0), (-1, -1), C_DARK),
+ ("TOPPADDING", (0, 0), (-1, -1), 10),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 10),
+ ("LEFTPADDING", (0, 0), (0, -1), 14),
+ ("RIGHTPADDING", (-1, 0), (-1, -1), 14),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ]))
+ story.append(hdr)
+ story.append(Spacer(1, 0.45 * cm))
+
+ # ── Meta block (recipient / date / validity) ──────────────────────────────
+ meta = Table(
+ [
+ [Paragraph("Empfänger", s_label), Paragraph(recipient_email, s_value),
+ Paragraph("Datum", s_label), Paragraph(today, s_body)],
+ [Paragraph("Plan", s_label), Paragraph(plan_name, s_body),
+ Paragraph("Gültig bis",s_label), Paragraph(validity, s_body)],
+ ],
+ colWidths=[2.8 * cm, CONTENT_W / 2 - 2.8 * cm, 2.2 * cm, CONTENT_W / 2 - 2.2 * cm],
+ )
+ meta.setStyle(TableStyle([
+ ("TOPPADDING", (0, 0), (-1, -1), 2),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 2),
+ ("LEFTPADDING", (0, 0), (-1, -1), 0),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 4),
+ ]))
+ story.append(meta)
+ story.append(Spacer(1, 0.3 * cm))
+ story.append(HRFlowable(width="100%", thickness=1.5, color=C_HIGHLIGHT, spaceAfter=0.35 * cm))
+
+ # ── Intro (one compact line) ───────────────────────────────────────────────
+ story.append(Paragraph(
+ "Sehr geehrte Damen und Herren, nachfolgend erhalten Sie unser Angebot für die Nutzung der "
+ "Onyva LinkedIn Content Automation Plattform.",
+ s_body,
+ ))
+ story.append(Spacer(1, 0.35 * cm))
+
+ # ── Section: Lizenzdetails ────────────────────────────────────────────────
+ story.append(Paragraph("LIZENZDETAILS", s_sec))
+ story.append(Spacer(1, 0.15 * cm))
+
+ # Compact limits bar (single row, two columns)
+ limits_bar = Table(
+ [[
+ Paragraph(f"Mitarbeiter: {max_employees} Nutzer", s_body),
+ Paragraph(f"KI-Token-Limit: {token_str}", s_body),
+ ]],
+ colWidths=[CONTENT_W / 2, CONTENT_W / 2],
+ )
+ limits_bar.setStyle(TableStyle([
+ ("BACKGROUND", (0, 0), (-1, -1), C_WHITE),
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
+ ("BOTTOMPADDING",(0, 0), (-1, -1), 6),
+ ("LEFTPADDING", (0, 0), (-1, -1), 10),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 10),
+ ("GRID", (0, 0), (-1, -1), 0.5, C_RULE),
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
+ ]))
+ story.append(limits_bar)
+ story.append(Spacer(1, 0.25 * cm))
+
+ # Features: 2-column grid
+ FEATURES = [
+ ("LinkedIn-Profil-Analyse", "KI-gestützt, einmalig pro Profil – Basis für individuelle Content-Strategie"),
+ ("Post-Typen & Strategie", "Definierbare Typen mit Gewichtung, KI-Analyse & Strategieanpassung"),
+ ("KI-Post-Erstellung", "Chat-basiert, iterativ, mit Qualitätsprüfung & Stilvalidierung"),
+ ("Mitarbeiterverwaltung", "Posts verwalten, kommentieren, freigeben oder ablehnen per UI"),
+ ("Research-Funktion", "Automatische Themenrecherche zu aktuellen Branchentrends"),
+ ("Posting-Kalender", "Planung & Terminierung von Posts über alle Mitarbeiter hinweg"),
+ ("E-Mail-Workflows", "Freigabe-Anfragen & Entscheidungsbenachrichtigungen per E-Mail"),
+ ]
+
+ def feat_cell(name, desc):
+ return Paragraph(
+ f'✓ {name}
'
+ f' {desc}',
+ ps(f"fc_{name[:4]}", fontSize=8.5, leading=13),
+ )
+
+ # Pair features into rows of 2
+ feat_rows = []
+ for i in range(0, len(FEATURES), 2):
+ left = feat_cell(*FEATURES[i])
+ right = feat_cell(*FEATURES[i + 1]) if i + 1 < len(FEATURES) else Paragraph("", s_small)
+ feat_rows.append([left, right])
+
+ feat_t = Table(feat_rows, colWidths=[HALF_W, HALF_W], hAlign="LEFT")
+ feat_t.setStyle(TableStyle([
+ ("BACKGROUND", (0, 0), (-1, -1), C_WHITE),
+ ("GRID", (0, 0), (-1, -1), 0.4, C_RULE),
+ ("TOPPADDING", (0, 0), (-1, -1), 5),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
+ ("LEFTPADDING", (0, 0), (-1, -1), 8),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 8),
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
+ ]))
+ story.append(feat_t)
+ story.append(Spacer(1, 0.4 * cm))
+
+ # ── Section: Preisgestaltung ──────────────────────────────────────────────
+ story.append(Paragraph("PREISGESTALTUNG", s_sec))
+ story.append(Spacer(1, 0.15 * cm))
+
+ price_rows = [
+ [Paragraph("Preis", s_label), Paragraph(price_str, s_price)],
+ [Paragraph("Zahlungsweise", s_label), Paragraph(freq_cap, s_body)],
+ ]
+ if payment_frequency == "jährlich":
+ price_rows.append([Paragraph("Jahresbetrag", s_label), Paragraph(_fmt_eur(price * 12), s_body)])
+
+ price_t = Table(price_rows, colWidths=[4 * cm, CONTENT_W - 4 * cm])
+ price_t.setStyle(TableStyle([
+ ("BACKGROUND", (0, 0), (-1, -1), C_WHITE),
+ ("GRID", (0, 0), (-1, -1), 0.5, C_RULE),
+ ("TOPPADDING", (0, 0), (-1, -1), 6),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
+ ("LEFTPADDING", (0, 0), (-1, -1), 10),
+ ("RIGHTPADDING", (0, 0), (-1, -1), 10),
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
+ ]))
+ story.append(price_t)
+ story.append(Spacer(1, 0.2 * cm))
+ story.append(Paragraph("Alle Preise zzgl. gesetzlicher Mehrwertsteuer.", s_small))
+ story.append(Spacer(1, 0.5 * cm))
+
+ # ── Footer ────────────────────────────────────────────────────────────────
+ story.append(HRFlowable(width="100%", thickness=0.5, color=C_RULE, spaceAfter=0.25 * cm))
+ story.append(Paragraph(
+ "Bei Fragen stehen wir Ihnen gerne zur Verfügung – Kontakt: team@onyva.de",
+ s_small,
+ ))
+ story.append(Spacer(1, 0.35 * cm))
+ story.append(Paragraph("Mit freundlichen Grüßen,
Onyva", s_body))
+
+ doc.build(story)
+ return buffer.getvalue()
+
+
+@admin_router.post("/api/license-keys/{key_id}/send-offer")
+async def send_license_offer(
+ request: Request,
+ key_id: str,
+ email: str = Form(...),
+ price: float = Form(...),
+ payment_frequency: str = Form(...),
+):
+ """Generate a PDF offer and send it via email."""
+ if not verify_auth(request):
+ raise HTTPException(status_code=401)
+
+ try:
+ key = await db.get_license_key_by_id(UUID(key_id))
+ if not key:
+ raise HTTPException(status_code=404, detail="Lizenzschlüssel nicht gefunden")
+
+ pdf_bytes = _build_offer_pdf(
+ key_description=key.description or "",
+ max_employees=key.max_employees,
+ daily_token_limit=key.daily_token_limit,
+ price=price,
+ payment_frequency=payment_frequency,
+ recipient_email=email,
+ )
+
+ posts_note = ""
+ if key.daily_token_limit:
+ posts_per_month = int(key.daily_token_limit * 30 / AVG_TOKENS_PER_POST)
+ token_str = f"{key.daily_token_limit:,}".replace(",", ".") + f" Tokens/Tag (ca. {posts_per_month} Posts/Monat)"
+ else:
+ token_str = "Unbegrenzt"
+
+ price_str = f"{price:,.2f} €".replace(",", "X").replace(".", ",").replace("X", ".")
+ plan_name = key.description or "Standard Plan"
+
+ html_body = f"""
+
Ihr persönliches Angebot
+Sehr geehrte Damen und Herren,
+
+ vielen Dank für Ihr Interesse an unserer LinkedIn Content Automation Lösung.
+ Im Anhang finden Sie Ihr persönliches Angebot als PDF-Dokument.
+
| Plan | {plan_name} |
| Mitarbeiter | {key.max_employees} Nutzer |
| Token-Limit | {token_str} |
| Preis | {price_str} |
| Zahlungsweise | {payment_frequency.capitalize()} |
Alle Preise zzgl. gesetzlicher MwSt. Das Angebot ist 30 Tage gültig.
+Bei Fragen stehen wir Ihnen gerne zur Verfügung.
+Mit freundlichen Grüßen,
Onyva
Onyva • team@onyva.de
+