managarten/docs/SYNC_BILLING_PLAN.md
Till JS 5c2ea614cd feat(credits): add sync billing — monthly credit subscription for cloud sync
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>
2026-04-10 22:21:58 +02:00

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

  1. User öffnet Settings → Sync → wählt Intervall (monatlich/quartalsweise/jährlich)
  2. System prüft Balance >= Preis für gewähltes Intervall
  3. Credits werden sofort abgebucht, nextChargeAt wird gesetzt
  4. Am Abrechnungstag: automatische Abbuchung via Cron
  5. Wenn Balance < Kosten: Sync wird pausiert, nicht gelöscht
    • In-App-Banner: "Sync pausiert — Credits aufladen um fortzufahren"
    • E-Mail-Notification via mana-notify
  6. Lokale Daten bleiben immer erhalten. Sobald Credits da → reaktivieren → alles synct nach
  7. 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 anlegen
  • deactivateSync(userId) — Subscription deaktivieren, keine Rückerstattung
  • getSyncStatus(userId){ active, interval, nextChargeAt, pausedAt }
  • chargeRecurring() — Cron: alle fälligen Subscriptions abrechnen, bei Insufficient-Balance pausieren
  • reactivateSync(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)

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:

  1. GET /api/v1/sync/status aufrufen
  2. Ergebnis in einen reaktiven Store (syncStatusStore) schreiben
  3. createUnifiedSync(..., syncStatus.active) aufrufen
  4. 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