Cloud Sync is now a paid feature: 30 credits/month (90/quarter, 360/year).
Users start in local-only mode and opt-in via Settings > Cloud Sync.
1 Credit = 1 Cent, so sync costs ~0.30€/month.
When credits run out, sync is paused (not deleted) and an in-app banner
prompts the user to top up. Local data is always preserved.
Backend (mana-credits):
- New sync_subscriptions table in credits schema
- SyncBillingService with activate/deactivate/chargeRecurring
- User-facing routes: GET/POST /api/v1/sync/{status,activate,deactivate,change-interval}
- Internal routes for server-side checks and cron triggers
Frontend (mana web):
- Sync API client + reactive sync-billing store
- syncEnabled parameter gates createUnifiedSync() — sync only starts when active
- Settings sync page with interval selection and activate/deactivate
- Pause banner in app layout when credits insufficient
Also: removed CALDAV_SYNC/GOOGLE_SYNC operations (not needed),
updated CLOUD_SYNC cost from 5 to 30 credits/month.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
9.4 KiB
Sync Billing Plan
Kontext
Sync (mana-sync, Go, Port 3050) ist das Rückgrat der Multi-Device-Erfahrung. Aktuell läuft Sync für jeden authentifizierten User kostenlos und automatisch — es gibt keine Abrechnungslogik. Die drei Sync-Operationen in @mana/credits (CALDAV_SYNC, GOOGLE_SYNC, CLOUD_SYNC) sind definiert aber nirgends enforced.
Sync verursacht laufende Server-Kosten (PostgreSQL, Go-Server, Bandwidth). Ein per-Operation-Pricing passt nicht — stattdessen eine monatliche Flat-Fee in Credits.
1 Credit = 1 Cent.
Modell
Sync als monatliches Credit-Abo
| Intervall | Preis | Pro Tag |
|---|---|---|
| Monatlich | 30 Credits (0.30€) | ~1 Credit/Tag |
| Quartalsweise | 90 Credits (0.90€) | ~1 Credit/Tag |
| Jährlich | 360 Credits (3.60€) | ~1 Credit/Tag |
Kein Rabatt bei längeren Intervallen — der Preis ist schon sehr niedrig. Der Vorteil längerer Intervalle ist Bequemlichkeit (seltener nachdenken).
Zwei Zustände
| Zustand | Was passiert |
|---|---|
| Local-Only (Default) | App funktioniert vollständig in IndexedDB. Kein Push/Pull. |
| Cloud Sync (Aktiv) | Multi-Device-Sync, Backup, Conflict Resolution via mana-sync. |
- Neue User starten immer in Local-Only
- Keine Welcome Credits — Credits kommen von Käufen, Geschenken oder Gutscheinen
- CalDAV/Google Sync: eventuell später, nicht Teil dieses Plans
Abrechnungs-Mechanik
- User öffnet Settings → Sync → wählt Intervall (monatlich/quartalsweise/jährlich)
- System prüft Balance >= Preis für gewähltes Intervall
- Credits werden sofort abgebucht,
nextChargeAtwird gesetzt - Am Abrechnungstag: automatische Abbuchung via Cron
- Wenn Balance < Kosten: Sync wird pausiert, nicht gelöscht
- In-App-Banner: "Sync pausiert — Credits aufladen um fortzufahren"
- E-Mail-Notification via mana-notify
- Lokale Daten bleiben immer erhalten. Sobald Credits da → reaktivieren → alles synct nach
- User kann jederzeit deaktivieren → keine weitere Abbuchung, Sync stoppt sofort
Ist-Zustand
| Komponente | Status | Datei |
|---|---|---|
| Sync-Engine (Client) | Startet automatisch nach Auth, kein Enable/Disable | apps/mana/apps/web/src/lib/data/sync.ts |
| Sync-Server | Akzeptiert alle authentifizierten Requests | services/mana-sync/ |
| Credit-Ops | CALDAV_SYNC, GOOGLE_SYNC, CLOUD_SYNC definiert, nie enforced |
packages/credits/src/operations.ts |
| Settings UI | Kein Sync-Toggle | routes/(app)/settings/+page.svelte |
Änderungen
1. Credit-Operationen bereinigen
Datei: packages/credits/src/operations.ts
CALDAV_SYNC und GOOGLE_SYNC entfernen (nicht Teil dieses Plans). CLOUD_SYNC behalten und Kosten anpassen:
CLOUD_SYNC = 'cloud_sync' // 30 Credits/Monat (oder 90/Quartal, 360/Jahr)
Metadata updaten: description: 'Cloud-Synchronisation über alle Geräte'
2. Sync-Subscriptions-Tabelle
Neue Datei: services/mana-credits/src/db/schema/sync.ts
-- In credits schema
CREATE TABLE credits.sync_subscriptions (
user_id TEXT PRIMARY KEY,
active BOOLEAN NOT NULL DEFAULT false,
billing_interval TEXT NOT NULL DEFAULT 'monthly', -- 'monthly' | 'quarterly' | 'yearly'
amount_charged INTEGER NOT NULL, -- 30, 90, oder 360
activated_at TIMESTAMPTZ,
next_charge_at TIMESTAMPTZ,
paused_at TIMESTAMPTZ, -- Gesetzt wenn Balance zu niedrig
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
Export in schema/index.ts hinzufügen.
3. Sync-Billing-Service
Neue Datei: services/mana-credits/src/services/sync-billing.ts
Methoden:
activateSync(userId, interval)— Balance prüfen, abbuchen, Subscription anlegendeactivateSync(userId)— Subscription deaktivieren, keine RückerstattunggetSyncStatus(userId)—{ active, interval, nextChargeAt, pausedAt }chargeRecurring()— Cron: alle fälligen Subscriptions abrechnen, bei Insufficient-Balance pausierenreactivateSync(userId)— Nach Pause: Balance prüfen, abbuchen, reaktivieren
Alle Credit-Bewegungen über den bestehenden CreditsService.useCredits() — immutable Ledger bleibt konsistent.
4. API-Endpoints
User-facing (JWT auth) — neue Route-Datei services/mana-credits/src/routes/sync.ts:
| Method | Path | Beschreibung |
|---|---|---|
| GET | /api/v1/sync/status |
Sync-Status des Users |
| POST | /api/v1/sync/activate |
Sync aktivieren (Body: `{ interval: 'monthly' |
| POST | /api/v1/sync/deactivate |
Sync deaktivieren |
| POST | /api/v1/sync/change-interval |
Intervall wechseln (ab nächster Abrechnung) |
Internal (X-Service-Key) — in bestehende routes/internal.ts einfügen:
| Method | Path | Beschreibung |
|---|---|---|
| GET | /api/v1/internal/sync/status/:userId |
Sync-Status für mana-sync Server-Check |
| POST | /api/v1/internal/sync/charge-recurring |
Cron-Trigger für monatliche Abbuchung |
5. Cron-Job für monatliche Abbuchung
Neue Datei: services/mana-credits/src/jobs/sync-charge.ts
- Läuft täglich (via mana-events Scheduler oder eigener Bun.cron)
- Query:
WHERE active = true AND next_charge_at <= NOW() - Für jeden:
useCredits()aufrufen- Erfolg:
next_charge_at += interval - Insufficient Credits:
active = false,paused_at = NOW() - Notification senden via mana-notify (In-App-Banner + E-Mail)
- Erfolg:
6. Client-seitiges Sync-Gating
Datei: apps/mana/apps/web/src/lib/data/sync.ts
createUnifiedSync() bekommt einen syncEnabled-Parameter:
export function createUnifiedSync(serverUrl: string, getToken: () => Promise<string>, syncEnabled: boolean) {
// Wenn !syncEnabled: startAll() wird zum No-Op, ensureAppSynced() gibt sofort zurück
}
Datei: apps/mana/apps/web/src/routes/(app)/+layout.svelte
Beim Auth-Ready:
GET /api/v1/sync/statusaufrufen- Ergebnis in einen reaktiven Store (
syncStatusStore) schreiben createUnifiedSync(..., syncStatus.active)aufrufen- Wenn
syncStatus.paused: Banner anzeigen
Neuer Store: apps/mana/apps/web/src/lib/stores/sync-status.svelte.ts
export const syncStatus = $state({
active: false,
interval: 'monthly' as 'monthly' | 'quarterly' | 'yearly',
nextChargeAt: null as string | null,
paused: false,
});
7. In-App-Banner bei pausiertem Sync
Datei: apps/mana/apps/web/src/routes/(app)/+layout.svelte (oder eigene Komponente)
Wenn syncStatus.paused:
⚠️ Cloud Sync pausiert — deine Credits reichen nicht aus.
[Credits aufladen] [Sync-Einstellungen]
Persistent am oberen Rand, dismissable aber kommt nach 24h wieder.
8. Settings UI
Neue Sektion in Settings oder eigene Route routes/(app)/settings/sync/+page.svelte:
- Status: Aktiv/Inaktiv/Pausiert
- Intervall-Auswahl: Monatlich (30 Credits) / Quartal (90) / Jahr (360)
- Info: Nächste Abbuchung am X, aktueller Kontostand: Y Credits
- Aktivieren/Deaktivieren-Button
- Hinweis bei Deaktivierung: "Deine Daten bleiben lokal erhalten. Du kannst jederzeit reaktivieren."
9. Server-seitiges Gating (mana-sync)
Datei: services/mana-sync/ (Go)
Middleware auf Push/Pull-Endpoints:
- Vor jedem Request:
GET /api/v1/internal/sync/status/:userId(cached 5 Minuten) - Bei
active = false: HTTP 402{ error: "sync_inactive", message: "Cloud Sync ist nicht aktiv" } - Client fängt 402 ab → setzt
syncStatus.paused = true→ zeigt Banner
10. E-Mail-Notification bei Pause
Über mana-notify (bestehender Service):
- Template: "Dein Cloud Sync wurde pausiert"
- Inhalt: Aktueller Kontostand, Link zu Credits kaufen, Link zu Settings
- Trigger: Wenn
chargeRecurring()eine Subscription pausiert
Dateien-Übersicht
| Aktion | Datei |
|---|---|
| Neu | services/mana-credits/src/db/schema/sync.ts |
| Neu | services/mana-credits/src/services/sync-billing.ts |
| Neu | services/mana-credits/src/routes/sync.ts |
| Neu | services/mana-credits/src/jobs/sync-charge.ts |
| Neu | apps/mana/apps/web/src/lib/stores/sync-status.svelte.ts |
| Neu | apps/mana/apps/web/src/routes/(app)/settings/sync/+page.svelte |
| Editieren | packages/credits/src/operations.ts — CALDAV/GOOGLE entfernen, CLOUD_SYNC auf 30 |
| Editieren | services/mana-credits/src/db/schema/index.ts — Sync-Schema exportieren |
| Editieren | services/mana-credits/src/index.ts — SyncBillingService + Sync-Routes registrieren |
| Editieren | services/mana-credits/src/routes/internal.ts — Sync-Status + Charge-Endpoint |
| Editieren | services/mana-credits/src/lib/validation.ts — Sync-Schemas |
| Editieren | apps/mana/apps/web/src/lib/data/sync.ts — Gating-Parameter |
| Editieren | apps/mana/apps/web/src/routes/(app)/+layout.svelte — Sync-Status laden |
| Editieren | apps/mana/apps/web/src/routes/(app)/settings/+page.svelte — Sync-Link |
| Editieren | services/mana-sync/ (Go) — Middleware für 402 |
Phasen
Phase 1: Backend + Client-Gating
- Schema, Service, API-Endpoints in mana-credits
- Sync-Status-Store + Gating in sync.ts
- Settings UI mit Activate/Deactivate
- In-App-Banner bei Pause
Phase 2: Server-Gating + Cron + Notifications
- mana-sync Go-Middleware (402 bei inaktivem Sync)
- Cron-Job für tägliche Abrechnung
- E-Mail-Notification via mana-notify bei Pause