Kosten-Management — Gesamtkonzept
Status
Konzept — noch nicht implementiert. Diese Seite beschreibt den Zielzustand für ein einheitliches Kosten- und Rechnungs-Management über Server, Module, Add-Ons und Mahn-Workflow.
Warum dieses Konzept?
Aktuell ist Prilogs Kosten- und Rechnungslogik fragmentiert:
- ServerOrder kennt nur einen
monthlyPrice(eine Decimal-Spalte) - ServerTier definiert Basispreis + Preis pro Nutzer
- SynapseModule hat optional einen
priceMonthly - ServerModule verbindet Order mit aktivierten Modulen — aber niemand summiert das automatisch auf
- Invoices existieren als API-Endpoint, geben aber bisher leeres Array zurück (
return { success: true, invoices: [] }) - Stripe ist halb angebunden (Customer + Subscription IDs in der DB), aber ohne automatische Synchronisation
- Mahn-Workflow (overdue → suspended → cancelled) ist nirgendwo implementiert
- Whisper läuft umsonst — Lee zahlt den Server-Tier ohne pro-Tenant-Tracking
Ergebnis: Lee kennt seinen tatsächlichen Monatsumsatz nur mit manuellem Kopfrechnen. Eine Schule die 6 Monate nicht zahlt, läuft trotzdem weiter. Modul-Aktivierungen führen zu keinem höheren Rechnungsbetrag.
Dieses Konzept löst das in einem zusammenhängenden Modell.
Zielbild in einem Bild
┌─────────────────────────────┐
│ AdminPortal (prilog-admin) │
│ │
│ • MRR / ARR Übersicht │
│ • Forderungs-Übersicht │
│ • Mahn-Stati pro Schule │
│ • Modul-Umsatz pro Modul │
└──────────────┬──────────────┘
│
▼
┌──────────────────┐ ┌──────────────────────────────┐ ┌──────────────────┐
│ ServerOrder │────────▶│ Cost Engine │◀────────│ Stripe │
│ (eine Schule) │ │ │ │ (Subscriptions) │
│ │ │ monthlyTotal = base │ │ │
│ + ServerTier │ │ + perUserCost │ │ Webhooks: │
│ + Module[] │ │ + sumModules │ │ paid / failed / │
│ + AddOns[] │ │ + sumAddOns │ │ past_due │
└──────────────────┘ │ │ └──────────────────┘
│ generateInvoice() monthly │
└──────────────┬───────────────┘
│
▼
┌────────────────────────────────┐
│ Invoice │
│ │
│ status: paid|overdue|suspended│ ┌──────────────────┐
│ dueDate, amount, items[] │────────▶│ KundenPortal │
│ pdfUrl │ │ (prilog-portal) │
└────────────────────────────────┘ │ │
│ │ Rechnungs-Liste │
│ │ ▢ grün ▢ orange │
▼ │ ▢ rot │
┌────────────────────────────────┐ │ Download PDF │
│ Mahn-Workflow │ └──────────────────┘
│ │
│ +14 Tage: 1. Mahnung (orange) │
│ +30 Tage: 2. Mahnung (orange) │
│ +45 Tage: Dienst suspend (rot)│
│ +60 Tage: Kündigung │
└────────────────────────────────┘§1 Datenmodell
1.1 Bestand (vorhanden)
| Modell | Zweck | Bestand |
|---|---|---|
ServerOrder.monthlyPrice | Monatspreis pro Schule | ✓ |
ServerTier | Server-Stufen mit basePrice, installationPrice, pricePerUser, usersUpTo | ✓ |
SynapseModule.priceMonthly | Modul-Preis (optional, null = im Grundpreis) | ✓ |
SynapseModule.stripePriceId | Stripe Price ID für recurring | ✓ |
ServerModule.stripeSubscriptionItemId | Stripe Subscription Item für aktiviertes Modul | ✓ |
ServerOrder.stripeCustomerId | Stripe Customer ID | ✓ |
ServerOrder.stripeSubscriptionId | Master Subscription | ✓ |
1.2 Neu (zu bauen)
Modell Invoice (heute leerer Stub, neu definieren)
model Invoice {
id String @id @default(cuid())
orderId Int @map("order_id")
invoiceNumber String @unique @map("invoice_number") @db.VarChar(20) // z.B. "2026-04-0042"
// Zeitraum
periodStart DateTime @map("period_start")
periodEnd DateTime @map("period_end")
issuedAt DateTime @default(now()) @map("issued_at")
dueAt DateTime @map("due_at") // typisch issuedAt + 14 Tage
// Beträge (alle in Euro, Decimal(10,2))
subtotalNet Decimal @map("subtotal_net") @db.Decimal(10, 2)
vatRate Decimal @default(19.0) @map("vat_rate") @db.Decimal(5, 2)
vatAmount Decimal @map("vat_amount") @db.Decimal(10, 2)
totalGross Decimal @map("total_gross") @db.Decimal(10, 2)
// Status (siehe §3 Mahn-Workflow)
status String @default("pending") @db.VarChar(20)
// pending → paid → ok
// pending → overdue (>dueAt) → reminded_1 → reminded_2 → suspended → cancelled
// Zahlungs-Tracking
paidAt DateTime? @map("paid_at")
paidAmount Decimal? @map("paid_amount") @db.Decimal(10, 2)
paymentMethod String? @map("payment_method") @db.VarChar(50) // stripe | sepa | bank | manual
stripeInvoiceId String? @unique @map("stripe_invoice_id") @db.VarChar(100)
// PDF
pdfUrl String? @map("pdf_url") @db.Text
pdfGeneratedAt DateTime? @map("pdf_generated_at")
// Mahnstufe (Anzahl bereits versendeter Mahnungen)
reminderLevel Int @default(0) @map("reminder_level")
lastReminderAt DateTime? @map("last_reminder_at")
suspendedAt DateTime? @map("suspended_at")
// Beziehungen
order ServerOrder @relation(fields: [orderId], references: [id])
items InvoiceItem[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([orderId, status])
@@index([dueAt, status]) // für Mahn-Cron
@@map("invoices")
}
model InvoiceItem {
id String @id @default(cuid())
invoiceId String @map("invoice_id")
// Was wird abgerechnet?
itemType String @db.VarChar(30) // server_base | per_user | module | addon | one_time
itemRefId String? @db.VarChar(50) // z.B. SynapseModule.id für Module
description String @db.VarChar(255) // "Server Tier 'Standard' (Apr 2026)" oder "Krisenmanagement-Modul (Apr 2026)"
quantity Decimal @default(1) @db.Decimal(10, 2) // z.B. Anzahl Nutzer bei per_user
unitPriceNet Decimal @map("unit_price_net") @db.Decimal(10, 4)
totalNet Decimal @map("total_net") @db.Decimal(10, 2)
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade)
@@index([invoiceId])
@@map("invoice_items")
}Erweiterung ServerOrder
model ServerOrder {
// ...bestehende Felder...
// Mahn-Status auf Tenant-Ebene (aggregiert über Invoices)
paymentHealthStatus String @default("ok") @map("payment_health_status") @db.VarChar(20)
// ok | overdue | suspended | cancelled
serviceSuspendedAt DateTime? @map("service_suspended_at")
// Beziehungen
invoices Invoice[]
}paymentHealthStatus ist die Single Source of Truth für die Kunden-Portal- Anzeige (grün/orange/rot) und für das Backend (darf der Tenant noch Module nutzen?).
1.3 Add-On-Modell
Neben Modulen brauchen wir auch Add-Ons (z.B. „Whisper Premium", „mehr Storage", „SMS-Versand-Kontingent"). Diese sind nicht im Modul-Katalog (SynapseModule) sondern eigenes Modell:
model AddOnCatalog {
id Int @id @default(autoincrement())
key String @unique @db.VarChar(50) // "extra_storage_50gb"
name String @db.VarChar(100) // "Zusätzlicher Storage 50 GB"
description String? @db.Text
category String @db.VarChar(50) // storage | transcription | sms | other
priceMonthly Decimal @map("price_monthly") @db.Decimal(10, 2)
unit String @default("flatrate") @db.VarChar(30) // flatrate | per_unit
active Boolean @default(true)
orderAddOns ServerOrderAddOn[]
@@map("addon_catalog")
}
model ServerOrderAddOn {
id Int @id @default(autoincrement())
orderId Int @map("order_id")
addOnId Int @map("addon_id")
quantity Int @default(1)
enabledAt DateTime @default(now()) @map("enabled_at")
disabledAt DateTime? @map("disabled_at")
order ServerOrder @relation(fields: [orderId], references: [id])
addOn AddOnCatalog @relation(fields: [addOnId], references: [id])
@@unique([orderId, addOnId])
@@map("server_order_addons")
}§2 Cost-Engine
Eine zentrale Service-Funktion calculateMonthlyTotal(orderId) berechnet den aktuellen Monatspreis einer Schule:
async function calculateMonthlyTotal(orderId: number): Promise<MonthlyBreakdown> {
const order = await prisma.serverOrder.findUnique({
where: { id: orderId },
include: {
serverTier: true,
modules: { include: { module: true } },
addOns: { include: { addOn: true } }
}
});
// 1. Server-Basispreis
const base = order.customPricePerUser
? Decimal(0) // Custom-Mode: nur per-User
: order.serverTier.basePrice;
// 2. Pro-Nutzer-Preis (bei Custom-Mode override, sonst Tier-Default)
const perUserPrice = order.customPricePerUser ?? order.serverTier.pricePerUser;
const userCount = order.staffCount + order.studentsCount + order.parentsCount;
const perUserTotal = perUserPrice.mul(userCount);
// 3. Modul-Aufschläge
const moduleTotal = order.modules
.filter(m => m.enabled && m.module.priceMonthly)
.reduce((sum, m) => sum.add(m.module.priceMonthly), Decimal(0));
// 4. Add-On-Aufschläge
const addOnTotal = order.addOns
.filter(a => !a.disabledAt)
.reduce((sum, a) => sum.add(a.addOn.priceMonthly.mul(a.quantity)), Decimal(0));
const subtotal = base.add(perUserTotal).add(moduleTotal).add(addOnTotal);
return {
items: [
{ type: 'server_base', label: order.serverTier.nameExternal, amount: base },
{ type: 'per_user', label: `${userCount} Nutzer × ${perUserPrice}€`, amount: perUserTotal },
...modulesItems,
...addOnItems,
],
subtotalNet: subtotal,
vatRate: 19,
vatAmount: subtotal.mul(0.19),
totalGross: subtotal.mul(1.19)
};
}Diese Funktion ist die einzige Stelle an der ein Monatspreis berechnet wird. Wird verwendet von:
- Admin-Dashboard (MRR/ARR-Berechnung)
- Cron-Job „generateInvoices" am 1. eines Monats
- Portal-Ansicht „Mein Server" — zeigt aktuelle Preis-Aufschlüsselung
- Onboarding-Wizard — bevor die Schule den Vertrag abschliesst
- Stripe-Sync — synct ServerOrder.monthlyPrice mit Stripe-Subscription
§3 Mahn-Workflow
Status-Übergänge einer Rechnung:
┌──────────────┐
│ pending │ (Rechnung ausgestellt, dueAt in 14 Tagen)
└──────┬───────┘
│
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌──────────┐
│ paid │ │ overdue │ │ disputed │ (manueller Override)
└─────────┘ └────┬─────┘ └──────────┘
│ +1 Tag nach dueAt
▼
┌──────────────┐
│ reminded_1 │ +14 Tage nach dueAt → Email "1. Erinnerung"
└──────┬───────┘
│
▼
┌──────────────┐
│ reminded_2 │ +28 Tage nach dueAt → Email "2. Mahnung"
└──────┬───────┘ + persönlicher Anruf empfohlen
│
▼
┌──────────────┐
│ suspended │ +45 Tage nach dueAt
└──────┬───────┘ → ServerOrder.paymentHealthStatus = 'suspended'
│ → Synapse → read-only Mode
│ → Web-Client zeigt Banner
│
▼
┌──────────────┐
│ cancelled │ +60 Tage nach dueAt
└──────────────┘ → Vertragskündigung, Daten-Export3.1 Mahn-Cron
Ein Cron-Job läuft täglich um 06:00 Uhr und macht Folgendes:
// src/core/cron/jobs.ts
registerCronJob({
key: 'invoice-dunning',
name: 'Mahnstufen aktualisieren',
schedule: '0 6 * * *',
enabled: true,
async handler() {
const now = new Date();
const overdueInvoices = await prisma.invoice.findMany({
where: { status: { in: ['pending', 'reminded_1', 'reminded_2'] } }
});
for (const inv of overdueInvoices) {
const daysOverdue = differenceInDays(now, inv.dueAt);
if (daysOverdue >= 45 && inv.status !== 'suspended') {
await suspendService(inv.orderId);
await prisma.invoice.update({ where: { id: inv.id }, data: { status: 'suspended', suspendedAt: now } });
} else if (daysOverdue >= 28 && inv.status !== 'reminded_2') {
await sendReminderEmail(inv, 2);
await prisma.invoice.update({ where: { id: inv.id }, data: { status: 'reminded_2', reminderLevel: 2, lastReminderAt: now } });
} else if (daysOverdue >= 14 && inv.status !== 'reminded_1') {
await sendReminderEmail(inv, 1);
await prisma.invoice.update({ where: { id: inv.id }, data: { status: 'reminded_1', reminderLevel: 1, lastReminderAt: now } });
} else if (daysOverdue > 0 && inv.status === 'pending') {
await prisma.invoice.update({ where: { id: inv.id }, data: { status: 'overdue' } });
}
}
// Aggregiere paymentHealthStatus auf ServerOrder
await refreshPaymentHealthStatus();
}
});3.2 Service-Unterbrechung
suspendService(orderId) macht Folgendes:
- Setzt
ServerOrder.paymentHealthStatus = 'suspended',serviceSuspendedAt = now - Sendet Synapse-Admin-Befehl → alle Räume des Tenants in „read only" (
m.room.history_visibilitybleibt, neue Messages werden via Modul-Hook geblockt) - Web-Client zeigt fullscreen Banner: „Dieser Prilog-Server ist wegen ausstehender Zahlung pausiert. Bitte kontaktiere [Kontakt-Email]"
- Sendet Email an Schul-Verwaltung + Prilog-Operator
- Schreibt Audit-Log-Eintrag
resumeService(orderId) (manuell oder nach Zahlungseingang):
- Setzt
paymentHealthStatus = 'ok',serviceSuspendedAt = null - Synapse → write-Mode wieder offen
- Web-Client-Banner verschwindet
- Email „Dienst wieder aktiv"
§4 UI-Spezifikation
4.1 Admin-Portal (prilog-admin)
Neue Seite /finance/dashboard:
┌───────────────────────────────────────────────────────────────┐
│ Finanzen — Übersicht │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ MRR │ │ ARR │ │ Aktive │ │ Overdue │ │ Susp. │ │
│ │ 2.480 € │ │ 29.760€ │ │ 18 │ │ 3 │ │ 1 │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ Modul-Umsatz aufgeschlüsselt │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Krisenmanagement 12 Schulen × 12 € = 144 €/Mo │ │
│ │ DMS Premium 4 Schulen × 19 € = 76 €/Mo │ │
│ │ Whisper-Transkription 8 Schulen × 9 € = 72 €/Mo │ │
│ └─────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────┘Neue Seite /finance/invoices:
Tabelle aller Rechnungen aller Schulen, filterbar nach Status. Klick auf eine Rechnung → Detail-Seite mit Items, Zahlungs-Verlauf, manueller „als bezahlt markieren"-Knopf.
Neue Seite /finance/dunning:
Liste aller offenen Forderungen, sortiert nach Verzugsdauer. Markiert Schulen die kurz vor Service-Unterbrechung stehen, mit Button „Persönlich kontaktiert"
- Notiz-Feld.
Erweiterung /orders/[orderId]:
Im Tab „Finanzen" eine Aufschlüsselung des aktuellen Monatspreises (calculateMonthlyTotal)
- Liste der bisherigen Rechnungen + manueller Override-Knopf für
customPricePerUser.
4.2 Kunden-Portal (prilog-portal)
Erweiterung /invoices:
┌─────────────────────────────────────────────────────────┐
│ Rechnungen │
│ │
│ Status: ● Bezahlt ◐ Im Verzug ● Dienst pausiert │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ ● 2026-04-0042 01.04.2026 95,20 € [⬇ PDF] │ │
│ │ Status: bezahlt am 03.04.2026 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ ◐ 2026-03-0041 01.03.2026 95,20 € [⬇ PDF] │ │
│ │ Fällig: 15.03.2026 — 27 Tage im Verzug │ │
│ │ 2. Mahnung versandt am 12.04.2026 │ │
│ │ ⚠ Bei weiterer Verzögerung wird der Dienst │ │
│ │ am 30.04.2026 vorübergehend pausiert. │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘Erweiterung /server (Seite „Mein Server"):
Zeigt eine Aufschlüsselung des aktuellen Monatspreises (vollständig nachvollziehbar):
Aktueller Monatspreis 95,20 € brutto
─────────────────────────────────────────
Server "Standard" Basis 7,00 €
+ 92 Nutzer × 0,40 € 36,80 €
+ Modul Krisenmanagement 12,00 €
+ Modul Whisper-Transkription 9,00 €
+ Add-On 50 GB Storage 15,00 €
─────────────────────────────────────────
Netto 79,80 €
+ MwSt 19% 15,16 €
─────────────────────────────────────────
Brutto 94,96 €(Differenz zur Gesamtsumme = Rundung auf vollen Cent)
4.3 Web-Client-Banner bei Suspension
Wenn paymentHealthStatus === 'suspended', zeigt das Web-Client einen vollflächigen Banner über dem Chat:
┌─────────────────────────────────────────────────────────┐
│ ⚠ Dieser Prilog-Server ist wegen ausstehender Zahlung │
│ pausiert. Lesen ist möglich, neue Nachrichten │
│ können nicht versendet werden. │
│ │
│ Bitte kontaktiere die Schul-Verwaltung oder │
│ leander@prilog.chat. │
└─────────────────────────────────────────────────────────┘§5 Stripe-Integration
Stripe ist die Quelle der Wahrheit für Zahlungseingänge. Die DB-Invoices sind Spiegel für interne Mahn-Logik und PDF-Generierung.
Webhook-Listener, der diese Stripe-Events behandelt:
| Stripe-Event | Was wir tun |
|---|---|
invoice.created | DB-Invoice anlegen mit stripeInvoiceId, status pending |
invoice.payment_succeeded | DB-Invoice auf paid, paidAt, paidAmount setzen, paymentHealthStatus refresh |
invoice.payment_failed | DB-Invoice bleibt pending (Mahn-Cron übernimmt) |
invoice.finalized | PDF-URL aus Stripe übernehmen |
customer.subscription.updated | Modul-Aktivierungen syncen (Subscription Items ↔ ServerModule) |
customer.subscription.deleted | Vertragskündigung verarbeiten |
Webhook-Endpoint: POST /api/admin/stripe/webhook (existiert bereits, muss erweitert werden).
§6 Whisper-Cost-Tracking
Optional, für später wenn Whisper monetarisiert werden soll.
Ansatz: pro Tenant ein Counter transcribedSecondsThisMonth in der DB. Wird bei jeder erfolgreichen Transkription incrementiert (in transcribe.service.ts nach transcribeAudio()-Erfolg).
Modell:
model TenantUsageStats {
id String @id @default(cuid())
tenantId String @map("tenant_id")
yearMonth String @db.VarChar(7) // "2026-04"
transcribedSeconds Int @default(0) @map("transcribed_seconds")
transcribedRequests Int @default(0) @map("transcribed_requests")
tenant Tenant @relation(fields: [tenantId], references: [id])
@@unique([tenantId, yearMonth])
@@map("tenant_usage_stats")
}Pricing-Optionen:
- A: flatrate — feste 9 €/Monat unabhängig von Nutzung (einfach, vorhersagbar für Schule)
- B: kontingent + überzogen — z.B. 9 € für 1000 s/Monat, danach 0,01 €/s
- C: rein verbrauchsabhängig — 0,01 €/s
Empfehlung: A (Flatrate) für die ersten 50 Schulen. Sobald wir > 100 Schulen oder einzelne Power-User haben, auf B umstellen weil wir sonst draufzahlen.
Damit das funktioniert müssen wir erstmal die tatsächlichen Whisper-Kosten pro Schule kennen — daher das Tracking auch ohne Monetarisierung jetzt schon sinnvoll, als interne Kostenstellen-Erfassung.
§7 PDF-Generierung
Wir haben aktuell keine PDF-Engine. Optionen:
| Option | Vorteil | Nachteil |
|---|---|---|
| Stripe Hosted Invoice PDFs | Kostenlos, automatisch, professionell | Wir kontrollieren Layout nicht, branding ist Stripe-typisch |
| PDFKit / pdfmake (node) | Volle Kontrolle, eigenes Layout | Aufwand: ~2 Tage, Wartung |
| HTML → PDF via Puppeteer | Wir nutzen unser bestehendes CSS, sehr flexibel | Puppeteer-Dependency, Chromium auf Server |
Empfehlung: Phase 1 → Stripe Hosted PDFs (Stripe liefert URL, wir verlinken). Phase 2 → eigenes Template mit Puppeteer wenn Branding wichtig wird.
§8 Phasen-Plan
| Phase | Inhalt | Aufwand | Abhängigkeit |
|---|---|---|---|
| 0 | Schema-Erweiterung (Invoice, InvoiceItem, AddOn-Modelle, paymentHealthStatus) | 2 h | – |
| 1 | calculateMonthlyTotal() Service + Tests | 4 h | 0 |
| 2 | Cron generateInvoices (1. eines Monats, alle aktiven Schulen) | 3 h | 1 |
| 3 | Stripe-Webhook erweitern (invoice.* Events) + Sync | 4 h | 0 |
| 4 | Mahn-Cron invoice-dunning (täglich 06:00) + Email-Templates | 6 h | 2, 3 |
| 5 | suspendService / resumeService + Web-Client-Banner | 4 h | 4 |
| 6 | Admin-UI: Finance-Dashboard + Invoices-Tabelle + Dunning-Liste | 8 h | 1, 2 |
| 7 | Portal-UI: erweiterte /invoices Seite + /server Preis-Aufschlüsselung | 4 h | 1, 2 |
| 8 | Whisper-Cost-Tracking (TenantUsageStats, Increment in transcribe.service) | 3 h | – (parallel) |
| 9 | Stripe-Test-Modus → Production-Switch + erste echte Rechnung | 2 h | 1–7 |
Gesamt-Aufwand: ca. 40 Stunden = 1 fokussierte Arbeitswoche
§9 KPIs für Lee
Sobald das System läuft, beantwortet das Admin-Dashboard diese Fragen ohne manuelles Rechnen:
| KPI | Wie wird es berechnet? |
|---|---|
| MRR (Monthly Recurring Revenue) | Σ aller calculateMonthlyTotal() für aktive Schulen |
| ARR (Annual Recurring Revenue) | MRR × 12 |
| Churn-Rate | Anteil cancelled Schulen / Total Schulen letzte 12 Monate |
| Average Revenue Per Account (ARPA) | MRR / aktive Schulen |
| Outstanding Receivables | Σ aller pending + overdue + reminded_* Invoices |
| Service Suspension Rate | Anteil suspended Schulen |
| Module Penetration | Pro Modul: % der Schulen die es aktiviert haben |
| Module Revenue | Pro Modul: Σ moduleItems aus laufenden Invoices |
§10 Offene Fragen / Entscheidungen
Vorauszahlung oder nachträglich? Aktuell impliziert das Konzept Vorauskasse (Rechnung am 1. des Monats für DEN Monat). Alternative: nachträglich (1. Mai für April). Vorauskasse ist üblich bei SaaS, vereinfacht Mahnungen. → Lee entscheidet.
Jahresrechnung-Option? Manche Schulen wollen lieber 1 × pro Jahr zahlen (mit Rabatt z.B. 10%). Sollte als optionale
billingInterval: monthly | yearlyauf ServerOrder umgesetzt werden. Phase 10+.Mehrwertsteuer: aktuell hardcoded 19%. Bei Schulen die als Körperschaft öffentlichen Rechts geführt sind, gelten teilweise Befreiungen. Sollte als Feld auf ServerOrder konfigurierbar sein (
vatExempt: boolean+vatExemptReason).Mahn-Gebühren: in Deutschland zulässig, aber das Konzept sieht keine vor. Bei Bedarf einfach in Phase 4 ergänzen (
InvoiceItem.itemType = 'reminder_fee').Sepa-Lastschrift: aktuell impliziert das Konzept Stripe-Card. SEPA-Direct-Debit ist über Stripe verfügbar, wäre aber zusätzlich zu konfigurieren. Wichtig für deutsche Schulen weil Karten unüblich.
Service-Suspension UX: wie hart wollen wir suspendieren?
- Soft: read-only, keine neuen Nachrichten
- Hart: kompletter Login-Block Empfehlung: soft für die ersten 14 Tage Suspension, dann hart wenn weiter keine Reaktion.
§11 Verwandte Themen
- Whisper-Server (Flurfunk) Setup — der zentrale Whisper-Dienst, dessen Kosten in dieses System integriert werden müssen
- Modul-Architektur Umsetzungsplan — wo das ServerModule-Modell ursprünglich definiert wurde
- Infrastruktur-Übersicht — Hetzner-Kosten als Eingangsparameter für die Profitabilitäts-Berechnung