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 ───────────────────────────────────── // ─── 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 channels = new Map<string, SyncChannelState>();
const clientId = getOrCreateClientId(); const clientId = getOrCreateClientId();
let status: SyncStatus = 'idle'; let status: SyncStatus = 'idle';
@ -540,6 +544,8 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise<str
// ─── Lifecycle ────────────────────────────────────────── // ─── Lifecycle ──────────────────────────────────────────
function startAll(): void { function startAll(): void {
if (!syncEnabled) return;
// Register all channels // Register all channels
for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
const channel: SyncChannelState = { 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. * If already synced (has pullTimer), this is a no-op.
*/ */
function ensureAppSynced(appId: string): void { function ensureAppSynced(appId: string): void {
if (!syncEnabled) return;
const channel = channels.get(appId); const channel = channels.get(appId);
if (!channel || channel.pullTimer) return; 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, stopMemoroLlmWatcher,
} from '$lib/modules/memoro/llm-watcher.svelte'; } from '$lib/modules/memoro/llm-watcher.svelte';
import { createUnifiedSync } from '$lib/data/sync'; import { createUnifiedSync } from '$lib/data/sync';
import { syncBilling } from '$lib/stores/sync-billing.svelte';
import { networkStore } from '$lib/stores/network.svelte'; import { networkStore } from '$lib/stores/network.svelte';
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { dashboardStore } from '$lib/stores/dashboard.svelte'; import { dashboardStore } from '$lib/stores/dashboard.svelte';
@ -447,8 +448,9 @@
if (authStore.isAuthenticated) { if (authStore.isAuthenticated) {
setErrorTrackingUser({ id: authStore.user?.id ?? 'unknown', email: authStore.user?.email }); setErrorTrackingUser({ id: authStore.user?.id ?? 'unknown', email: authStore.user?.email });
trackReturnVisit(); trackReturnVisit();
await syncBilling.load();
const getToken = () => authStore.getValidToken(); 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 // Expose on window for SYNC_DEBUG.md (Schritt C). Not a security
// concern: every method on the returned object is also reachable // concern: every method on the returned object is also reachable
// via Dexie + a fresh fetch from the same DevTools console, and // via Dexie + a fresh fetch from the same DevTools console, and
@ -618,6 +620,25 @@
<EncryptionIntroBanner /> <EncryptionIntroBanner />
</div> </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 <!-- Guest notifications — combines the time-based nudge from
createGuestMode (one-shot after N minutes) with the createGuestMode (one-shot after N minutes) with the
event-driven prompts pushed by guestPrompt.requireAccount event-driven prompts pushed by guestPrompt.requireAccount

View file

@ -170,6 +170,30 @@
<!-- Global Settings Section (synced across all apps) --> <!-- Global Settings Section (synced across all apps) -->
<GlobalSettingsSection {userSettings} appId="mana" /> <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 --> <!-- Credits Section -->
<Card> <Card>
<div class="p-6"> <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}

232
docs/SYNC_BILLING_PLAN.md Normal file
View file

@ -0,0 +1,232 @@
# 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:
```typescript
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`
```sql
-- 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' | 'quarterly' | 'yearly' }`) |
| 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:
```typescript
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`
```typescript
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

View file

@ -62,9 +62,7 @@ export enum CreditOperationType {
// Premium Features (Standard Credits: 0.5-5) // Premium Features (Standard Credits: 0.5-5)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Sync features // Sync
CALDAV_SYNC = 'caldav_sync',
GOOGLE_SYNC = 'google_sync',
CLOUD_SYNC = 'cloud_sync', CLOUD_SYNC = 'cloud_sync',
// Import/Export // Import/Export
@ -113,9 +111,7 @@ export const CREDIT_COSTS: Record<CreditOperationType, number> = {
[CreditOperationType.AI_ENRICHMENT]: 2, [CreditOperationType.AI_ENRICHMENT]: 2,
// Premium Features // Premium Features
[CreditOperationType.CALDAV_SYNC]: 0.5, [CreditOperationType.CLOUD_SYNC]: 30, // Monthly (or 90/quarterly, 360/yearly)
[CreditOperationType.GOOGLE_SYNC]: 0.5,
[CreditOperationType.CLOUD_SYNC]: 5, // Monthly
[CreditOperationType.BULK_IMPORT]: 0.2, // Per 10 items [CreditOperationType.BULK_IMPORT]: 0.2, // Per 10 items
[CreditOperationType.PDF_EXPORT]: 1, [CreditOperationType.PDF_EXPORT]: 1,
@ -132,7 +128,6 @@ export const CREDIT_COSTS: Record<CreditOperationType, number> = {
*/ */
export enum CreditCategory { export enum CreditCategory {
AI = 'ai', AI = 'ai',
PRODUCTIVITY = 'productivity',
PREMIUM = 'premium', PREMIUM = 'premium',
} }
@ -293,23 +288,11 @@ export const OPERATION_METADATA: Record<CreditOperationType, OperationMetadata>
}, },
// Premium - Sync // Premium - Sync
[CreditOperationType.CALDAV_SYNC]: {
name: 'CalDAV Sync',
description: 'Sync with CalDAV server',
category: CreditCategory.PREMIUM,
app: 'calendar',
},
[CreditOperationType.GOOGLE_SYNC]: {
name: 'Google Sync',
description: 'Sync with Google services',
category: CreditCategory.PREMIUM,
app: 'contacts',
},
[CreditOperationType.CLOUD_SYNC]: { [CreditOperationType.CLOUD_SYNC]: {
name: 'Cloud Sync (Monthly)', name: 'Cloud Sync',
description: 'Enable cloud synchronization', description: 'Cloud-Synchronisation über alle Geräte (30 Credits/Monat)',
category: CreditCategory.PREMIUM, category: CreditCategory.PREMIUM,
app: 'skilltree', app: 'general',
}, },
// Premium - Import/Export // Premium - Import/Export

View file

@ -39,6 +39,15 @@ bun run db:studio # Open Drizzle Studio
| POST | `/api/v1/credits/purchase` | Initiate Stripe purchase | | POST | `/api/v1/credits/purchase` | Initiate Stripe purchase |
| GET | `/api/v1/credits/purchase/:id` | Purchase status | | GET | `/api/v1/credits/purchase/:id` | Purchase status |
### Sync Billing (JWT auth)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/sync/status` | Sync subscription status |
| POST | `/api/v1/sync/activate` | Activate sync (body: `{ interval }`) |
| POST | `/api/v1/sync/deactivate` | Deactivate sync |
| POST | `/api/v1/sync/change-interval` | Change billing interval |
### Gift Codes (Mixed auth) ### Gift Codes (Mixed auth)
| Method | Path | Description | | Method | Path | Description |
@ -59,6 +68,8 @@ bun run db:studio # Open Drizzle Studio
| POST | `/api/v1/internal/credits/refund` | Refund credits | | POST | `/api/v1/internal/credits/refund` | Refund credits |
| POST | `/api/v1/internal/credits/init` | Initialize balance | | POST | `/api/v1/internal/credits/init` | Initialize balance |
| POST | `/api/v1/internal/gifts/redeem-pending` | Auto-redeem on registration | | POST | `/api/v1/internal/gifts/redeem-pending` | Auto-redeem on registration |
| GET | `/api/v1/internal/sync/status/:userId` | Sync status for server check |
| POST | `/api/v1/internal/sync/charge-recurring` | Cron trigger for billing |
### Webhooks ### Webhooks
@ -85,16 +96,23 @@ Own database: `mana_credits`
Schemas: `credits.*`, `gifts.*` Schemas: `credits.*`, `gifts.*`
Tables: balances, transactions, packages, purchases, usage_stats, stripe_customers, gift_codes, gift_redemptions Tables: balances, transactions, packages, purchases, usage_stats, stripe_customers, gift_codes, gift_redemptions, sync_subscriptions
## Credit Operations ## Credit Operations
Credits are only charged for operations that cost real money: Credits are only charged for operations that cost real money:
- **AI operations** (2-25 credits): Chat with GPT-4/Claude/Gemini, image generation, research, food/plant analysis - **AI operations** (2-25 credits): Chat with GPT-4/Claude/Gemini, image generation, research, food/plant analysis
- **Premium features** (0.5-5 credits): CalDAV/Google sync, cloud sync, PDF export, bulk import - **Premium features** (1-3 credits): PDF export, bulk import, premium themes
- **Cloud Sync** (30 credits/month, 90/quarter, 360/year): Multi-device sync via mana-sync
Local-first CRUD operations (tasks, events, contacts, etc.) are **free** — they happen in IndexedDB with no server cost. Local-first CRUD operations (tasks, events, contacts, etc.) are **free** — they happen in IndexedDB with no server cost.
## Sync Billing
Cloud Sync is a monthly credit subscription. Users start in local-only mode and opt-in via Settings. Billing intervals: monthly (30), quarterly (90), yearly (360). 1 Credit = 1 Cent.
When credits run out, sync is paused (not deleted). Local data is preserved. User sees an in-app banner and can reactivate after topping up credits.
## Gift Types ## Gift Types
Two gift types: `simple` (anyone with code can redeem) and `personalized` (auto-redeemed when target email registers). Each gift is single-use. Two gift types: `simple` (anyone with code can redeem) and `personalized` (auto-redeemed when target email registers). Each gift is single-use.

View file

@ -1,2 +1,3 @@
export * from './credits'; export * from './credits';
export * from './gifts'; export * from './gifts';
export * from './sync';

View file

@ -0,0 +1,34 @@
/**
* Sync Billing Schema sync subscription tracking
*/
import { text, integer, boolean, timestamp, pgEnum } from 'drizzle-orm/pg-core';
import { creditsSchema } from './credits';
// ─── Enums ──────────────────────────────────────────────────
export const syncBillingIntervalEnum = pgEnum('sync_billing_interval', [
'monthly',
'quarterly',
'yearly',
]);
// ─── Tables ─────────────────────────────────────────────────
/** Sync subscriptions — one per user, tracks billing state */
export const syncSubscriptions = creditsSchema.table('sync_subscriptions', {
userId: text('user_id').primaryKey(),
active: boolean('active').default(false).notNull(),
billingInterval: syncBillingIntervalEnum('billing_interval').notNull().default('monthly'),
amountCharged: integer('amount_charged').notNull().default(30),
activatedAt: timestamp('activated_at', { withTimezone: true }),
nextChargeAt: timestamp('next_charge_at', { withTimezone: true }),
pausedAt: timestamp('paused_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// ─── Type Exports ───────────────────────────────────────────
export type SyncSubscription = typeof syncSubscriptions.$inferSelect;
export type NewSyncSubscription = typeof syncSubscriptions.$inferInsert;

View file

@ -2,7 +2,7 @@
* mana-credits Standalone credit management service * mana-credits Standalone credit management service
* *
* Hono + Bun runtime. Extracted from mana-auth. * Hono + Bun runtime. Extracted from mana-auth.
* Handles: personal credits, gift codes, Stripe payments. * Handles: personal credits, gift codes, sync billing, Stripe payments.
*/ */
import { Hono } from 'hono'; import { Hono } from 'hono';
@ -15,9 +15,11 @@ import { serviceAuth } from './middleware/service-auth';
import { StripeService } from './services/stripe'; import { StripeService } from './services/stripe';
import { CreditsService } from './services/credits'; import { CreditsService } from './services/credits';
import { GiftCodeService } from './services/gift-code'; import { GiftCodeService } from './services/gift-code';
import { SyncBillingService } from './services/sync-billing';
import { healthRoutes } from './routes/health'; import { healthRoutes } from './routes/health';
import { createCreditsRoutes } from './routes/credits'; import { createCreditsRoutes } from './routes/credits';
import { createGiftRoutes } from './routes/gifts'; import { createGiftRoutes } from './routes/gifts';
import { createSyncRoutes } from './routes/sync';
import { createInternalRoutes } from './routes/internal'; import { createInternalRoutes } from './routes/internal';
import { createWebhookRoutes } from './routes/stripe-webhook'; import { createWebhookRoutes } from './routes/stripe-webhook';
@ -30,6 +32,7 @@ const db = getDb(config.databaseUrl);
const stripeService = new StripeService(db, config.stripe.secretKey); const stripeService = new StripeService(db, config.stripe.secretKey);
const creditsService = new CreditsService(db, stripeService); const creditsService = new CreditsService(db, stripeService);
const giftCodeService = new GiftCodeService(db, config.baseUrl); const giftCodeService = new GiftCodeService(db, config.baseUrl);
const syncBillingService = new SyncBillingService(db, creditsService);
// ─── App ──────────────────────────────────────────────────── // ─── App ────────────────────────────────────────────────────
@ -52,12 +55,18 @@ app.route('/health', healthRoutes);
app.use('/api/v1/credits/*', jwtAuth(config.manaAuthUrl)); app.use('/api/v1/credits/*', jwtAuth(config.manaAuthUrl));
app.route('/api/v1/credits', createCreditsRoutes(creditsService)); app.route('/api/v1/credits', createCreditsRoutes(creditsService));
app.use('/api/v1/sync/*', jwtAuth(config.manaAuthUrl));
app.route('/api/v1/sync', createSyncRoutes(syncBillingService));
// Gift routes (mixed: public GET /:code, JWT for rest) // Gift routes (mixed: public GET /:code, JWT for rest)
app.route('/api/v1/gifts', createGiftRoutes(giftCodeService, config.manaAuthUrl)); app.route('/api/v1/gifts', createGiftRoutes(giftCodeService, config.manaAuthUrl));
// Service-to-service routes (X-Service-Key auth) // Service-to-service routes (X-Service-Key auth)
app.use('/api/v1/internal/*', serviceAuth(config.serviceKey)); app.use('/api/v1/internal/*', serviceAuth(config.serviceKey));
app.route('/api/v1/internal', createInternalRoutes(creditsService, giftCodeService)); app.route(
'/api/v1/internal',
createInternalRoutes(creditsService, giftCodeService, syncBillingService)
);
// Stripe webhooks (verified by signature, no auth middleware) // Stripe webhooks (verified by signature, no auth middleware)
app.route( app.route(

View file

@ -37,6 +37,16 @@ export const redeemGiftSchema = z.object({
sourceAppId: z.string().optional(), sourceAppId: z.string().optional(),
}); });
// ─── Sync ──────────────────────────────────────────────────
export const activateSyncSchema = z.object({
interval: z.enum(['monthly', 'quarterly', 'yearly']).default('monthly'),
});
export const changeSyncIntervalSchema = z.object({
interval: z.enum(['monthly', 'quarterly', 'yearly']),
});
// ─── Internal (Service-to-Service) ────────────────────────── // ─── Internal (Service-to-Service) ──────────────────────────
export const internalUseCreditsSchema = z.object({ export const internalUseCreditsSchema = z.object({

View file

@ -5,6 +5,7 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { CreditsService } from '../services/credits'; import type { CreditsService } from '../services/credits';
import type { GiftCodeService } from '../services/gift-code'; import type { GiftCodeService } from '../services/gift-code';
import type { SyncBillingService } from '../services/sync-billing';
import { import {
internalUseCreditsSchema, internalUseCreditsSchema,
internalRefundSchema, internalRefundSchema,
@ -14,7 +15,8 @@ import {
export function createInternalRoutes( export function createInternalRoutes(
creditsService: CreditsService, creditsService: CreditsService,
giftCodeService: GiftCodeService giftCodeService: GiftCodeService,
syncBillingService: SyncBillingService
) { ) {
return new Hono() return new Hono()
.get('/credits/balance/:userId', async (c) => { .get('/credits/balance/:userId', async (c) => {
@ -47,5 +49,13 @@ export function createInternalRoutes(
const body = internalRedeemPendingSchema.parse(await c.req.json()); const body = internalRedeemPendingSchema.parse(await c.req.json());
const result = await giftCodeService.redeemPendingForUser(body.userId, body.email); const result = await giftCodeService.redeemPendingForUser(body.userId, body.email);
return c.json(result); return c.json(result);
})
.get('/sync/status/:userId', async (c) => {
const status = await syncBillingService.getSyncStatus(c.req.param('userId'));
return c.json(status);
})
.post('/sync/charge-recurring', async (c) => {
const result = await syncBillingService.chargeRecurring();
return c.json(result);
}); });
} }

View file

@ -0,0 +1,34 @@
/**
* Sync billing routes user-facing endpoints (JWT auth)
*/
import { Hono } from 'hono';
import type { SyncBillingService } from '../services/sync-billing';
import type { AuthUser } from '../middleware/jwt-auth';
import { activateSyncSchema, changeSyncIntervalSchema } from '../lib/validation';
export function createSyncRoutes(syncBillingService: SyncBillingService) {
return new Hono<{ Variables: { user: AuthUser } }>()
.get('/status', async (c) => {
const user = c.get('user');
const status = await syncBillingService.getSyncStatus(user.userId);
return c.json(status);
})
.post('/activate', async (c) => {
const user = c.get('user');
const body = activateSyncSchema.parse(await c.req.json());
const result = await syncBillingService.activateSync(user.userId, body.interval);
return c.json(result);
})
.post('/deactivate', async (c) => {
const user = c.get('user');
const result = await syncBillingService.deactivateSync(user.userId);
return c.json(result);
})
.post('/change-interval', async (c) => {
const user = c.get('user');
const body = changeSyncIntervalSchema.parse(await c.req.json());
const result = await syncBillingService.changeBillingInterval(user.userId, body.interval);
return c.json(result);
});
}

View file

@ -0,0 +1,235 @@
/**
* Sync Billing Service manages sync subscriptions and recurring charges
*/
import { eq, and, lte } from 'drizzle-orm';
import { syncSubscriptions } from '../db/schema/sync';
import type { Database } from '../db/connection';
import type { CreditsService } from './credits';
import { BadRequestError, NotFoundError, InsufficientCreditsError } from '../lib/errors';
type BillingInterval = 'monthly' | 'quarterly' | 'yearly';
const SYNC_PRICES: Record<BillingInterval, number> = {
monthly: 30,
quarterly: 90,
yearly: 360,
};
function getNextChargeDate(from: Date, interval: BillingInterval): Date {
const next = new Date(from);
switch (interval) {
case 'monthly':
next.setMonth(next.getMonth() + 1);
break;
case 'quarterly':
next.setMonth(next.getMonth() + 3);
break;
case 'yearly':
next.setFullYear(next.getFullYear() + 1);
break;
}
return next;
}
export class SyncBillingService {
constructor(
private db: Database,
private creditsService: CreditsService
) {}
async getSyncStatus(userId: string) {
const [sub] = await this.db
.select()
.from(syncSubscriptions)
.where(eq(syncSubscriptions.userId, userId))
.limit(1);
if (!sub) {
return {
active: false,
interval: 'monthly' as BillingInterval,
nextChargeAt: null,
pausedAt: null,
};
}
return {
active: sub.active,
interval: sub.billingInterval as BillingInterval,
nextChargeAt: sub.nextChargeAt?.toISOString() ?? null,
pausedAt: sub.pausedAt?.toISOString() ?? null,
};
}
async activateSync(userId: string, interval: BillingInterval = 'monthly') {
const amount = SYNC_PRICES[interval];
const now = new Date();
// Check if already active
const [existing] = await this.db
.select()
.from(syncSubscriptions)
.where(eq(syncSubscriptions.userId, userId))
.limit(1);
if (existing?.active) {
throw new BadRequestError('Sync is already active');
}
// Charge credits
await this.creditsService.useCredits(userId, {
amount,
appId: 'sync',
description: `Cloud Sync activated (${interval})`,
metadata: { interval, type: 'sync_subscription' },
});
const nextChargeAt = getNextChargeDate(now, interval);
if (existing) {
// Reactivate existing subscription
await this.db
.update(syncSubscriptions)
.set({
active: true,
billingInterval: interval,
amountCharged: amount,
activatedAt: now,
nextChargeAt,
pausedAt: null,
updatedAt: now,
})
.where(eq(syncSubscriptions.userId, userId));
} else {
// Create new subscription
await this.db.insert(syncSubscriptions).values({
userId,
active: true,
billingInterval: interval,
amountCharged: amount,
activatedAt: now,
nextChargeAt,
});
}
return {
success: true,
active: true,
interval,
nextChargeAt: nextChargeAt.toISOString(),
amountCharged: amount,
};
}
async deactivateSync(userId: string) {
const [sub] = await this.db
.select()
.from(syncSubscriptions)
.where(eq(syncSubscriptions.userId, userId))
.limit(1);
if (!sub || !sub.active) {
throw new BadRequestError('Sync is not active');
}
await this.db
.update(syncSubscriptions)
.set({
active: false,
nextChargeAt: null,
updatedAt: new Date(),
})
.where(eq(syncSubscriptions.userId, userId));
return { success: true };
}
async changeBillingInterval(userId: string, newInterval: BillingInterval) {
const [sub] = await this.db
.select()
.from(syncSubscriptions)
.where(eq(syncSubscriptions.userId, userId))
.limit(1);
if (!sub || !sub.active) {
throw new BadRequestError('Sync is not active');
}
const newAmount = SYNC_PRICES[newInterval];
// Change takes effect at next billing cycle
await this.db
.update(syncSubscriptions)
.set({
billingInterval: newInterval,
amountCharged: newAmount,
updatedAt: new Date(),
})
.where(eq(syncSubscriptions.userId, userId));
return {
success: true,
interval: newInterval,
amountCharged: newAmount,
effectiveAt: sub.nextChargeAt?.toISOString() ?? null,
};
}
/**
* Charge all due sync subscriptions. Called by cron job (daily).
* Returns summary of charges, pauses, and errors.
*/
async chargeRecurring() {
const now = new Date();
const dueSubscriptions = await this.db
.select()
.from(syncSubscriptions)
.where(and(eq(syncSubscriptions.active, true), lte(syncSubscriptions.nextChargeAt, now)));
let charged = 0;
let paused = 0;
let errors = 0;
for (const sub of dueSubscriptions) {
try {
await this.creditsService.useCredits(sub.userId, {
amount: sub.amountCharged,
appId: 'sync',
description: `Cloud Sync renewal (${sub.billingInterval})`,
metadata: { interval: sub.billingInterval, type: 'sync_renewal' },
});
// Update next charge date
const nextChargeAt = getNextChargeDate(now, sub.billingInterval as BillingInterval);
await this.db
.update(syncSubscriptions)
.set({ nextChargeAt, updatedAt: now })
.where(eq(syncSubscriptions.userId, sub.userId));
charged++;
} catch (error) {
if (error instanceof InsufficientCreditsError) {
// Pause subscription
await this.db
.update(syncSubscriptions)
.set({
active: false,
pausedAt: now,
updatedAt: now,
})
.where(eq(syncSubscriptions.userId, sub.userId));
paused++;
// TODO Phase 2: send notification via mana-notify
} else {
errors++;
console.error(`[sync-billing] Failed to charge user ${sub.userId}:`, error);
}
}
}
return { charged, paused, errors, total: dueSubscriptions.length };
}
}