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>
This commit is contained in:
Till JS 2026-04-10 22:21:58 +02:00
parent f9b6720d15
commit 5c2ea614cd
16 changed files with 1082 additions and 29 deletions

View file

@ -0,0 +1,76 @@
/**
* Sync Billing Service for Mana Web App
* Handles sync subscription status, activation, and deactivation
*/
import { authStore } from '$lib/stores/auth.svelte';
import { getManaAuthUrl } from './config';
// Types
export type BillingInterval = 'monthly' | 'quarterly' | 'yearly';
export interface SyncStatus {
active: boolean;
interval: BillingInterval;
nextChargeAt: string | null;
pausedAt: string | null;
}
export interface SyncActivateResponse {
success: boolean;
active: boolean;
interval: BillingInterval;
nextChargeAt: string;
amountCharged: number;
}
// Helper
async function fetchWithAuth<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const token = await authStore.getAccessToken();
const response = await fetch(`${getManaAuthUrl()}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
// Sync Service
export const syncService = {
async getSyncStatus(): Promise<SyncStatus> {
return fetchWithAuth<SyncStatus>('/api/v1/sync/status');
},
async activateSync(interval: BillingInterval = 'monthly'): Promise<SyncActivateResponse> {
return fetchWithAuth<SyncActivateResponse>('/api/v1/sync/activate', {
method: 'POST',
body: JSON.stringify({ interval }),
});
},
async deactivateSync(): Promise<{ success: boolean }> {
return fetchWithAuth<{ success: boolean }>('/api/v1/sync/deactivate', {
method: 'POST',
body: JSON.stringify({}),
});
},
async changeInterval(
interval: BillingInterval
): Promise<{ success: boolean; interval: BillingInterval; amountCharged: number }> {
return fetchWithAuth('/api/v1/sync/change-interval', {
method: 'POST',
body: JSON.stringify({ interval }),
});
},
};

View file

@ -529,7 +529,11 @@ const EAGER_APPS = new Set([
]);
// ─── Unified Sync Manager ─────────────────────────────────────
export function createUnifiedSync(serverUrl: string, getToken: () => Promise<string | null>) {
export function createUnifiedSync(
serverUrl: string,
getToken: () => Promise<string | null>,
syncEnabled = true
) {
const channels = new Map<string, SyncChannelState>();
const clientId = getOrCreateClientId();
let status: SyncStatus = 'idle';
@ -540,6 +544,8 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise<str
// ─── Lifecycle ──────────────────────────────────────────
function startAll(): void {
if (!syncEnabled) return;
// Register all channels
for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
const channel: SyncChannelState = {
@ -1058,6 +1064,7 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise<str
* If already synced (has pullTimer), this is a no-op.
*/
function ensureAppSynced(appId: string): void {
if (!syncEnabled) return;
const channel = channels.get(appId);
if (!channel || channel.pullTimer) return;

View file

@ -0,0 +1,68 @@
/**
* Sync Billing Store tracks sync subscription state
*/
import { syncService, type BillingInterval } from '$lib/api/sync';
let active = $state(false);
let interval = $state<BillingInterval>('monthly');
let nextChargeAt = $state<string | null>(null);
let paused = $state(false);
let loading = $state(true);
export const syncBilling = {
get active() {
return active;
},
get interval() {
return interval;
},
get nextChargeAt() {
return nextChargeAt;
},
get paused() {
return paused;
},
get loading() {
return loading;
},
async load() {
loading = true;
try {
const status = await syncService.getSyncStatus();
active = status.active;
interval = status.interval;
nextChargeAt = status.nextChargeAt;
paused = status.pausedAt !== null && !status.active;
} catch (e) {
console.error('[sync-billing] Failed to load status:', e);
// Default to inactive on error
active = false;
paused = false;
} finally {
loading = false;
}
},
async activate(billingInterval: BillingInterval = 'monthly') {
const result = await syncService.activateSync(billingInterval);
active = result.active;
interval = result.interval;
nextChargeAt = result.nextChargeAt;
paused = false;
return result;
},
async deactivate() {
await syncService.deactivateSync();
active = false;
nextChargeAt = null;
paused = false;
},
async changeInterval(newInterval: BillingInterval) {
const result = await syncService.changeInterval(newInterval);
interval = result.interval;
},
};

View file

@ -47,6 +47,7 @@
stopMemoroLlmWatcher,
} from '$lib/modules/memoro/llm-watcher.svelte';
import { createUnifiedSync } from '$lib/data/sync';
import { syncBilling } from '$lib/stores/sync-billing.svelte';
import { networkStore } from '$lib/stores/network.svelte';
import { db } from '$lib/data/database';
import { dashboardStore } from '$lib/stores/dashboard.svelte';
@ -447,8 +448,9 @@
if (authStore.isAuthenticated) {
setErrorTrackingUser({ id: authStore.user?.id ?? 'unknown', email: authStore.user?.email });
trackReturnVisit();
await syncBilling.load();
const getToken = () => authStore.getValidToken();
unifiedSync = createUnifiedSync(SYNC_SERVER_URL, getToken);
unifiedSync = createUnifiedSync(SYNC_SERVER_URL, getToken, syncBilling.active);
// Expose on window for SYNC_DEBUG.md (Schritt C). Not a security
// concern: every method on the returned object is also reachable
// via Dexie + a fresh fetch from the same DevTools console, and
@ -618,6 +620,25 @@
<EncryptionIntroBanner />
</div>
<!-- Sync pause banner — shown when sync was paused due to insufficient credits -->
{#if syncBilling.paused}
<div class="bottom-stack-notification">
<div
class="flex items-center justify-between gap-3 rounded-lg bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:bg-amber-900/20 dark:text-amber-200"
>
<span>Cloud Sync pausiert — Credits reichen nicht aus.</span>
<div class="flex gap-2">
<a href="/credits?tab=packages" class="font-medium underline hover:no-underline">
Credits aufladen
</a>
<a href="/settings/sync" class="font-medium underline hover:no-underline">
Sync-Einstellungen
</a>
</div>
</div>
</div>
{/if}
<!-- Guest notifications — combines the time-based nudge from
createGuestMode (one-shot after N minutes) with the
event-driven prompts pushed by guestPrompt.requireAccount

View file

@ -170,6 +170,30 @@
<!-- Global Settings Section (synced across all apps) -->
<GlobalSettingsSection {userSettings} appId="mana" />
<!-- Cloud Sync Section -->
<Card>
<div class="p-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400"
>
<span class="text-lg">☁️</span>
</div>
<div>
<h2 class="text-lg font-semibold">Cloud Sync</h2>
<p class="text-sm text-muted-foreground">
Synchronisiere deine Daten über alle Geräte
</p>
</div>
</div>
<a href="/settings/sync" class="text-sm text-primary hover:underline">
Einstellungen
</a>
</div>
</div>
</Card>
<!-- Credits Section -->
<Card>
<div class="p-6">

View file

@ -0,0 +1,291 @@
<script lang="ts">
import { Card, PageHeader } from '@mana/shared-ui';
import { syncBilling } from '$lib/stores/sync-billing.svelte';
import { creditsService, type CreditBalance } from '$lib/api/credits';
import type { BillingInterval } from '$lib/api/sync';
import { onMount } from 'svelte';
const SYNC_PRICES: Record<BillingInterval, number> = {
monthly: 30,
quarterly: 90,
yearly: 360,
};
const INTERVAL_LABELS: Record<BillingInterval, string> = {
monthly: 'Monatlich',
quarterly: 'Quartalsweise',
yearly: 'Jährlich',
};
let balance = $state<CreditBalance | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
let selectedInterval = $state<BillingInterval>('monthly');
// Toast
let toastMessage = $state<string | null>(null);
let toastType = $state<'success' | 'error'>('success');
onMount(async () => {
await Promise.all([syncBilling.load(), loadBalance()]);
selectedInterval = syncBilling.interval;
});
async function loadBalance() {
try {
balance = await creditsService.getBalance();
} catch {
// Non-critical
}
}
async function handleActivate() {
loading = true;
error = null;
try {
await syncBilling.activate(selectedInterval);
await loadBalance();
showToast('Cloud Sync aktiviert!', 'success');
} catch (e) {
error = e instanceof Error ? e.message : 'Aktivierung fehlgeschlagen';
showToast(error, 'error');
} finally {
loading = false;
}
}
async function handleDeactivate() {
if (!confirm('Cloud Sync wirklich deaktivieren? Deine Daten bleiben lokal erhalten.')) return;
loading = true;
error = null;
try {
await syncBilling.deactivate();
showToast('Cloud Sync deaktiviert', 'success');
} catch (e) {
error = e instanceof Error ? e.message : 'Deaktivierung fehlgeschlagen';
showToast(error, 'error');
} finally {
loading = false;
}
}
async function handleChangeInterval() {
if (selectedInterval === syncBilling.interval) return;
loading = true;
error = null;
try {
await syncBilling.changeInterval(selectedInterval);
showToast(`Intervall auf ${INTERVAL_LABELS[selectedInterval]} geändert`, 'success');
} catch (e) {
error = e instanceof Error ? e.message : 'Änderung fehlgeschlagen';
showToast(error, 'error');
} finally {
loading = false;
}
}
function formatCredits(amount: number): string {
return amount.toLocaleString('de-DE');
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function showToast(message: string, type: 'success' | 'error') {
toastMessage = message;
toastType = type;
setTimeout(() => {
toastMessage = null;
}, 4000);
}
</script>
<div>
<PageHeader
title="Cloud Sync"
description="Synchronisiere deine Daten über alle Geräte"
size="lg"
/>
{#if syncBilling.loading}
<div class="flex items-center justify-center py-12">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
></div>
</div>
{:else}
<div class="grid gap-6 lg:grid-cols-2">
<!-- Status Card -->
<Card>
<div class="p-6">
<h2 class="text-lg font-semibold mb-4">Status</h2>
<div class="flex items-center gap-3 mb-6">
<div
class="flex h-12 w-12 items-center justify-center rounded-full {syncBilling.active
? 'bg-green-100 dark:bg-green-900/20'
: syncBilling.paused
? 'bg-amber-100 dark:bg-amber-900/20'
: 'bg-surface'}"
>
<span class="text-2xl"
>{syncBilling.active ? '🔄' : syncBilling.paused ? '⏸️' : '☁️'}</span
>
</div>
<div>
<p class="text-xl font-bold">
{#if syncBilling.active}
Aktiv
{:else if syncBilling.paused}
Pausiert
{:else}
Inaktiv
{/if}
</p>
{#if syncBilling.active && syncBilling.nextChargeAt}
<p class="text-sm text-muted-foreground">
Nächste Abbuchung: {formatDate(syncBilling.nextChargeAt)}
</p>
{:else if syncBilling.paused}
<p class="text-sm text-amber-600 dark:text-amber-400">
Credits reichen nicht aus — lade Credits auf um fortzufahren
</p>
{:else}
<p class="text-sm text-muted-foreground">
Deine Daten sind nur lokal auf diesem Gerät gespeichert
</p>
{/if}
</div>
</div>
{#if balance}
<div class="rounded-lg bg-surface p-4 mb-6">
<div class="flex justify-between items-center">
<span class="text-sm text-muted-foreground">Verfügbare Credits</span>
<span class="text-lg font-bold text-primary">{formatCredits(balance.balance)}</span>
</div>
</div>
{/if}
{#if error}
<div class="mb-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-4">
<p class="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
{/if}
{#if syncBilling.active}
<button
onclick={handleDeactivate}
disabled={loading}
class="w-full rounded-lg border border-red-300 dark:border-red-700 py-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/10 transition-colors disabled:opacity-50"
>
{loading ? 'Wird deaktiviert...' : 'Cloud Sync deaktivieren'}
</button>
{:else}
<button
onclick={handleActivate}
disabled={loading ||
(balance !== null && balance.balance < SYNC_PRICES[selectedInterval])}
class="w-full rounded-lg bg-primary py-3 font-semibold text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{#if loading}
Wird aktiviert...
{:else}
Cloud Sync aktivieren ({SYNC_PRICES[selectedInterval]} Credits)
{/if}
</button>
{#if balance !== null && balance.balance < SYNC_PRICES[selectedInterval]}
<p class="mt-2 text-center text-sm text-amber-600 dark:text-amber-400">
Nicht genügend Credits.
<a href="/credits?tab=packages" class="underline hover:no-underline">Aufladen</a>
</p>
{/if}
{/if}
</div>
</Card>
<!-- Interval Selection Card -->
<Card>
<div class="p-6">
<h2 class="text-lg font-semibold mb-4">Abrechnungsintervall</h2>
<div class="space-y-3">
{#each ['monthly', 'quarterly', 'yearly'] as const as iv}
<label
class="flex items-center justify-between rounded-lg border p-4 cursor-pointer transition-colors {selectedInterval ===
iv
? 'border-primary bg-primary/5'
: 'border-border hover:bg-surface'}"
>
<div class="flex items-center gap-3">
<input
type="radio"
name="interval"
value={iv}
checked={selectedInterval === iv}
onchange={() => (selectedInterval = iv)}
class="h-4 w-4 text-primary"
/>
<div>
<p class="font-medium">{INTERVAL_LABELS[iv]}</p>
<p class="text-sm text-muted-foreground">
{iv === 'monthly'
? '~1 Credit/Tag'
: iv === 'quarterly'
? '3 Monate'
: '12 Monate'}
</p>
</div>
</div>
<span class="text-lg font-bold text-primary">{SYNC_PRICES[iv]}</span>
</label>
{/each}
</div>
{#if syncBilling.active && selectedInterval !== syncBilling.interval}
<button
onclick={handleChangeInterval}
disabled={loading}
class="mt-4 w-full rounded-lg bg-surface py-2 font-medium text-foreground hover:bg-surface-hover border border-border transition-colors disabled:opacity-50"
>
{loading ? 'Wird geändert...' : `Auf ${INTERVAL_LABELS[selectedInterval]} wechseln`}
</button>
<p class="mt-2 text-center text-xs text-muted-foreground">
Änderung gilt ab der nächsten Abbuchung
</p>
{/if}
<div class="mt-6 rounded-lg bg-blue-50 dark:bg-blue-950/30 p-4">
<p class="text-sm text-blue-800 dark:text-blue-200">
Cloud Sync synchronisiert deine Daten verschlüsselt über alle Geräte. Deine lokalen
Daten bleiben immer erhalten — auch wenn Sync pausiert oder deaktiviert wird.
</p>
</div>
</div>
</Card>
</div>
<!-- Back link -->
<div class="mt-6">
<a href="/settings" class="text-sm text-primary hover:underline">
← Zurück zu Einstellungen
</a>
</div>
{/if}
</div>
<!-- Toast Notification -->
{#if toastMessage}
<div
class="fixed bottom-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg {toastType === 'success'
? 'bg-green-600 text-white'
: 'bg-red-600 text-white'}"
>
{toastMessage}
</div>
{/if}