Freemium mit 90-Tage-Hide + 12-Monats-Wipe — Konzept
Stand: 2026-04-30 (Revision 2: Hide statt Delete) Modell: 90 Tage gratis testen, danach werden Inhalte aelter 90 Tage ausgeblendet (nicht geloescht). Mit Abo (3 EUR/aktivem User/Monat) sofort wieder voll sichtbar. Hard-Delete nur bei explizitem Konto-Schluss oder bei 12+ Monaten Inaktivitaet ohne Abo.
1. Drei-Stufen-Modell
| Stufe | Trigger | Wirkung |
|---|---|---|
| Sichtbar | Default (Abo aktiv ODER alles juenger 90d) | Alle Daten zugaenglich |
| Hidden | Ohne Abo, Inhalte aelter 90d | Per Query-Filter ausgeblendet. Banner: "X Tage alte Inhalte ausgeblendet — Abo aktiviert sie wieder." |
| Deleted | (a) Admin klickt "Konto schliessen" → 30d Gnadenfrist + Hard-Delete (b) Tenant 12+ Monate kein Login UND kein Abo → Auto-Cancel-Mail + 30d + Wipe | DSGVO-konformer Hard-Delete inkl. S3 + Synapse |
Vorteile:
- Risiko-arm: keine versehentliche Loeschung. Bug im Filter? Daten sind noch da.
- Conversion-stark: "Klick → alles wieder da" ist staerker als "zahl, sonst weg".
- Reaktivierung trivial: Pause-Tenant kommt zurueck → Abo → weiter wie vorher.
- Implementation: ~4 Tage statt ~8 Tage (kein S3-Cleanup-Cron, kein Synapse-Per-Room-Retention).
2. Tabellen-Inventur
Drei Kategorien (Compliance + Platform-Global aus Inventur Rev. 1 unveraendert).
A) Hide-Filter pflichtig (User-Content)
Diese Tabellen bekommen einen WHERE-Filter im Listen-/Detail-Service. Nicht alle Tabellen brauchen ein Filter — Strukturobjekte (Boards, UserTypes etc.) bleiben immer sichtbar, nur Items darin werden gefiltert.
| Tabelle / Service | Hide-Filter auf | Bemerkung |
|---|---|---|
Document (DMS-Liste, Suche) | updatedAt | inkl. Volltextsuche |
WorkItem (Tasks-Liste) | updatedAt | offene Tasks bleiben durch updatedAt |
WorkItemComment | filtert mit Parent | |
CalendarEvent | updatedAt ODER endsAt | Recurring Events: recurrenceUntil |
AbsenceEntry | updatedAt | |
MitteilungsheftEntry | updatedAt | |
SpacePost + Responses | updatedAt | |
MorningCheck + Entry | createdAt | event-typisch, kein Update |
CollabDocument | updatedAt | Y.js wird oft geupdated, daher harmlos |
GeneratedReport | createdAt | |
Distribution | createdAt | |
InboxDropMeta | createdAt | |
ProcessInstance + Events | updatedAt UND status='completed' | laufende Flows immer sichtbar |
CrisisReport | createdAt | |
SchoolTripEvent | endsAt | |
SpaceActivityDay | date | Heatmap-Datenpunkt |
Tag / DocumentTag / ContactTag | filtert mit Parent | |
Favorite | createdAt | |
SpaceEmail + InboundEmail + Replies | createdAt | |
MessageReadReceipt | filtert mit Message | |
| Matrix-Messages (Synapse) | origin_server_ts | Client-seitiger Timeline-Filter (siehe §4.3) |
B) Strukturell — kein Filter (sonst kollabiert der Tenant)
Tenant, User, UserDirectoryEntry, Membership, Space, SpaceManager, UserType, TenantSetting, ApiKey, TenantModuleInstallation, ServerOrder, Plan, Addon, RoleDefault, RolePermissionPolicy, BoardGroup, Board, FamilyRelation, Templates (SpaceTemplate, ConceptTemplate, ReportTemplate, SpacePostTemplate), ProcessTemplate/Component/Edge, AccessRequest, UserInvitation, PortalAccount, DeveloperAccount, SavedFilter, UserPostfachSettings, UserOnboarding, LayerSubscription, CalendarLayer.
C) Compliance — exempt (10-Jahres- bis dauerhaft-Aufbewahrung)
ChildProtectionCase, Invoice, InvoiceItem, AvvConsentLog, EmailLog, ImpersonationLog, ImpersonationNotice, PostfachAuditLog, CrisisAuditEntry, UserDirectoryHistory, ProvisionStep/Script, InstallationLog, Credential, MaintenanceSnapshot, Ticket/Reply, SyncJob/Event, HealEvent, SmokeTestRun.
3. Schema-Aenderungen
model Tenant {
...bestehend...
subscriptionStatus String @default("trial") // 'trial' | 'active' | 'cancelled'
subscriptionStartedAt DateTime?
subscriptionEndedAt DateTime?
creditCents Int @default(0)
stripeCustomerId String?
stripeSubscriptionId String?
// Fuer 12-Monats-Zombie-Detection:
lastActivityAt DateTime? // letzter Login eines Users
scheduledDeletionAt DateTime? // wenn gesetzt, Hard-Delete an dem Datum
}
model UserActivity {
id String @id @default(uuid())
tenantId String
userId String
periodMonth String // 'YYYY-MM'
loginCount Int @default(0)
writeCount Int @default(0)
lastSeenAt DateTime
@@unique([userId, periodMonth])
@@index([tenantId, periodMonth])
}4. Implementierung
4.1 Hide-Filter (Hauptarbeit)
Ein zentraler Helper:
// services/data-visibility.service.ts
export async function getVisibilityCutoff(tenantId: string): Promise<Date | null> {
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId } });
if (tenant?.subscriptionStatus === 'active') return null; // alles sichtbar
return new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
}Pro Service der Liste A:
const cutoff = await getVisibilityCutoff(tenantId);
const where = {
tenantId,
...(cutoff ? { updatedAt: { gte: cutoff } } : {}),
};Geschaetzt: 20-25 Service-Files anzupassen.
4.2 Active-User-Tracking
- Login-Hook: Bei
/auth/login-Erfolg UpsertUserActivity { loginCount++ } - Write-Hook: Bei Message-Send, Document-Create, WorkItem-Create/Update, CalendarEvent-Create, Comment-Create Upsert
writeCount++ - Aktiv:
loginCount > 0 AND writeCount > 0im Period-Monat - Tenant.lastActivityAt wird bei jedem Login gesetzt (fuer Zombie-Detection)
4.3 Matrix-Timeline-Filter
Synapse selbst loescht/blendet nichts aus. Aber: der Web-Client laedt Messages via /sync und stellt sie selbst dar. Wir filtern client-seitig in der Render-Pipeline:
// chat-store.ts
const cutoffTs = sessionStore.getSnapshot().subscription?.visibilityCutoff;
const visibleMessages = messages.filter(m => !cutoffTs || m.timestamp >= cutoffTs);Das visibilityCutoff kommt aus dem Bootstrap-Endpoint. Bei aktivem Abo: null → alles sichtbar.
4.4 Banner + Status-Endpoint
GET /platform/v1/subscription/statusliefert:json{ "status": "trial", "trialDaysLeft": 12, "hiddenSince": "2026-01-30", "creditCents": 600 }- Banner-Logik:
status === 'trial' && trialDaysLeft > 7: kein Bannerstatus === 'trial' && trialDaysLeft <= 7: gelber Banner "X Tage bis erste Inhalte ausgeblendet werden"status === 'trial' && hiddenSince: roter Banner "Inhalte aelter X Tage sind ausgeblendet — [Jetzt 3 EUR/User abonnieren]"
4.5 Stripe-Integration
- Checkout: Stripe-Checkout-Session mit Subscription-Mode (3 EUR/User-Preis konfiguriert in Stripe-Dashboard)
- Webhook:
customer.subscription.created→subscriptionStatus = 'active'.subscription.deleted→'cancelled'. - Billing-Cron (monatlich, 1. um 06:00 UTC):
Pro Tenant mit subscriptionStatus='active': activeUsers = count(UserActivity wo periodMonth=lastMonth UND loginCount>0 UND writeCount>0) grossCents = activeUsers * 300 netCents = max(0, grossCents - tenant.creditCents) if netCents > 0: stripe.invoiceItems.create + stripe.invoices.finalize inactiveUsers = membership.count - activeUsers creditCents += inactiveUsers * 300 Cap: creditCents = min(creditCents, 6 * membership.count * 300)
4.6 Hard-Delete-Pfade
Pfad 1 — Admin schliesst Konto:
- Settings → Rechnungen → "Konto schliessen" → Bestaetigungs-Modal mit "Daten werden in 30 Tagen unwiderruflich geloescht"
Tenant.scheduledDeletionAt = now() + 30d- Banner: "Dein Konto wird am DD.MM.YYYY geloescht. [Abbrechen]"
- 30d-Cron prueft
scheduledDeletionAt < now()→ Hard-Wipe
Pfad 2 — 12-Monats-Zombie:
- Cron taeglich: Tenants mit
subscriptionStatus != 'active'UNDlastActivityAt < now() - 365dUNDscheduledDeletionAt IS NULL - Setze
scheduledDeletionAt = now() + 30d, sende Mail an Admin: "Inaktivitaet seit 12 Monaten — Konto wird geloescht. [Reaktivieren-Link]"
Hard-Wipe-Job:
- Loescht Tenant + alle abhaengigen Tabellen (cascade)
- Loescht S3-Bucket fuer den Tenant
- Loescht Synapse-Raeume via Admin-API
- Schreibt
AvvConsentLog-Eintrag (DSGVO-Loesch-Beleg, exempt von der Wipe selbst)
4.7 Onboarding-Update
- Signup-Page: "90 Tage gratis. Keine Karte noetig. Danach 3 EUR pro aktivem User/Monat."
- Welcome-Modal beim ersten Login: "Inhalte aelter 90 Tage werden ohne Abo ausgeblendet — nie geloescht. Click zum Abo bringt sie zurueck."
5. Phasenplan
| # | Phase | Aufwand |
|---|---|---|
| 1 | Schema-Migration (Tenant + UserActivity) | 0.5d |
| 2 | Active-User-Tracking (Login + Write Hooks) | 1d |
| 3 | Hide-Filter in 20-25 Services + Matrix-Timeline-Filter im Web-Client | 1.5d |
| 4 | Banner + Subscription-Status-Endpoint | 0.5d |
| 5 | Stripe-Integration (Checkout + Webhook + Billing-Cron) | 2d |
| 6 | Onboarding-Update + Welcome-Modal | 0.5d |
| 7 | Hard-Delete-Pfade (Admin-Schliessung + 12M-Zombie) | 1d |
| 8 | E2E-Test + Doku | 0.5d |
Gesamt: ~7.5 Tage (vs. 8d in Rev. 1 — kaum gespart, aber Risiko deutlich kleiner und Conversion staerker)
6. Offene Fragen (vor Phase 1)
- Filter-Feld:
updatedAtals Default — OK? - Aktiv-Definition:
loginCount > 0 AND writeCount > 0im Monat — OK? - Credit-Cap: 6 Monate (entspricht halbjaehrlicher Vorrat) — OK?
- Trial-Verlaengerung: Soll Admin Trial selbst um +30d schieben koennen oder nur Vollabo?
- Member-Count fuer Credit: alle PrilogMemberships oder nur "echte" User (keine Bots)?