diff --git a/apps/mana/apps/web/src/lib/api/sync.ts b/apps/mana/apps/web/src/lib/api/sync.ts new file mode 100644 index 000000000..6bf487126 --- /dev/null +++ b/apps/mana/apps/web/src/lib/api/sync.ts @@ -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(endpoint: string, options: RequestInit = {}): Promise { + 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 { + return fetchWithAuth('/api/v1/sync/status'); + }, + + async activateSync(interval: BillingInterval = 'monthly'): Promise { + return fetchWithAuth('/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 }), + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/data/sync.ts b/apps/mana/apps/web/src/lib/data/sync.ts index cf672eb1c..7e4c1a23d 100644 --- a/apps/mana/apps/web/src/lib/data/sync.ts +++ b/apps/mana/apps/web/src/lib/data/sync.ts @@ -529,7 +529,11 @@ const EAGER_APPS = new Set([ ]); // ─── Unified Sync Manager ───────────────────────────────────── -export function createUnifiedSync(serverUrl: string, getToken: () => Promise) { +export function createUnifiedSync( + serverUrl: string, + getToken: () => Promise, + syncEnabled = true +) { const channels = new Map(); const clientId = getOrCreateClientId(); let status: SyncStatus = 'idle'; @@ -540,6 +544,8 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise Promise('monthly'); +let nextChargeAt = $state(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; + }, +}; diff --git a/apps/mana/apps/web/src/routes/(app)/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/+layout.svelte index b1c88a375..917edf4b6 100644 --- a/apps/mana/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/mana/apps/web/src/routes/(app)/+layout.svelte @@ -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 @@ + + {#if syncBilling.paused} +
+
+ Cloud Sync pausiert — Credits reichen nicht aus. + +
+
+ {/if} + + + +
+
+
+
+ ☁️ +
+
+

Cloud Sync

+

+ Synchronisiere deine Daten über alle Geräte +

+
+
+ + Einstellungen + +
+
+
+
diff --git a/apps/mana/apps/web/src/routes/(app)/settings/sync/+page.svelte b/apps/mana/apps/web/src/routes/(app)/settings/sync/+page.svelte new file mode 100644 index 000000000..3d50857bd --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/settings/sync/+page.svelte @@ -0,0 +1,291 @@ + + +
+ + + {#if syncBilling.loading} +
+
+
+ {:else} +
+ + +
+

Status

+ +
+
+ {syncBilling.active ? '🔄' : syncBilling.paused ? '⏸️' : '☁️'} +
+
+

+ {#if syncBilling.active} + Aktiv + {:else if syncBilling.paused} + Pausiert + {:else} + Inaktiv + {/if} +

+ {#if syncBilling.active && syncBilling.nextChargeAt} +

+ Nächste Abbuchung: {formatDate(syncBilling.nextChargeAt)} +

+ {:else if syncBilling.paused} +

+ Credits reichen nicht aus — lade Credits auf um fortzufahren +

+ {:else} +

+ Deine Daten sind nur lokal auf diesem Gerät gespeichert +

+ {/if} +
+
+ + {#if balance} +
+
+ Verfügbare Credits + {formatCredits(balance.balance)} +
+
+ {/if} + + {#if error} +
+

{error}

+
+ {/if} + + {#if syncBilling.active} + + {:else} + + {#if balance !== null && balance.balance < SYNC_PRICES[selectedInterval]} +

+ Nicht genügend Credits. + Aufladen +

+ {/if} + {/if} +
+
+ + + +
+

Abrechnungsintervall

+ +
+ {#each ['monthly', 'quarterly', 'yearly'] as const as iv} + + {/each} +
+ + {#if syncBilling.active && selectedInterval !== syncBilling.interval} + +

+ Änderung gilt ab der nächsten Abbuchung +

+ {/if} + +
+

+ Cloud Sync synchronisiert deine Daten verschlüsselt über alle Geräte. Deine lokalen + Daten bleiben immer erhalten — auch wenn Sync pausiert oder deaktiviert wird. +

+
+
+
+
+ + + + {/if} +
+ + +{#if toastMessage} +
+ {toastMessage} +
+{/if} diff --git a/docs/SYNC_BILLING_PLAN.md b/docs/SYNC_BILLING_PLAN.md new file mode 100644 index 000000000..19b8df9b8 --- /dev/null +++ b/docs/SYNC_BILLING_PLAN.md @@ -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, 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 diff --git a/packages/credits/src/operations.ts b/packages/credits/src/operations.ts index b99091df9..55316fb8e 100644 --- a/packages/credits/src/operations.ts +++ b/packages/credits/src/operations.ts @@ -62,9 +62,7 @@ export enum CreditOperationType { // Premium Features (Standard Credits: 0.5-5) // ------------------------------------------------------------------------- - // Sync features - CALDAV_SYNC = 'caldav_sync', - GOOGLE_SYNC = 'google_sync', + // Sync CLOUD_SYNC = 'cloud_sync', // Import/Export @@ -113,9 +111,7 @@ export const CREDIT_COSTS: Record = { [CreditOperationType.AI_ENRICHMENT]: 2, // Premium Features - [CreditOperationType.CALDAV_SYNC]: 0.5, - [CreditOperationType.GOOGLE_SYNC]: 0.5, - [CreditOperationType.CLOUD_SYNC]: 5, // Monthly + [CreditOperationType.CLOUD_SYNC]: 30, // Monthly (or 90/quarterly, 360/yearly) [CreditOperationType.BULK_IMPORT]: 0.2, // Per 10 items [CreditOperationType.PDF_EXPORT]: 1, @@ -132,7 +128,6 @@ export const CREDIT_COSTS: Record = { */ export enum CreditCategory { AI = 'ai', - PRODUCTIVITY = 'productivity', PREMIUM = 'premium', } @@ -293,23 +288,11 @@ export const OPERATION_METADATA: Record }, // 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]: { - name: 'Cloud Sync (Monthly)', - description: 'Enable cloud synchronization', + name: 'Cloud Sync', + description: 'Cloud-Synchronisation über alle Geräte (30 Credits/Monat)', category: CreditCategory.PREMIUM, - app: 'skilltree', + app: 'general', }, // Premium - Import/Export diff --git a/services/mana-credits/CLAUDE.md b/services/mana-credits/CLAUDE.md index 042f71455..47f435e9e 100644 --- a/services/mana-credits/CLAUDE.md +++ b/services/mana-credits/CLAUDE.md @@ -39,6 +39,15 @@ bun run db:studio # Open Drizzle Studio | POST | `/api/v1/credits/purchase` | Initiate Stripe purchase | | 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) | 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/init` | Initialize balance | | 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 @@ -85,16 +96,23 @@ Own database: `mana_credits` 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 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 -- **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. +## 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 Two gift types: `simple` (anyone with code can redeem) and `personalized` (auto-redeemed when target email registers). Each gift is single-use. diff --git a/services/mana-credits/src/db/schema/index.ts b/services/mana-credits/src/db/schema/index.ts index d57676050..54f0401d4 100644 --- a/services/mana-credits/src/db/schema/index.ts +++ b/services/mana-credits/src/db/schema/index.ts @@ -1,2 +1,3 @@ export * from './credits'; export * from './gifts'; +export * from './sync'; diff --git a/services/mana-credits/src/db/schema/sync.ts b/services/mana-credits/src/db/schema/sync.ts new file mode 100644 index 000000000..5039cc476 --- /dev/null +++ b/services/mana-credits/src/db/schema/sync.ts @@ -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; diff --git a/services/mana-credits/src/index.ts b/services/mana-credits/src/index.ts index 12172dffc..17cb06e38 100644 --- a/services/mana-credits/src/index.ts +++ b/services/mana-credits/src/index.ts @@ -2,7 +2,7 @@ * mana-credits — Standalone credit management service * * 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'; @@ -15,9 +15,11 @@ import { serviceAuth } from './middleware/service-auth'; import { StripeService } from './services/stripe'; import { CreditsService } from './services/credits'; import { GiftCodeService } from './services/gift-code'; +import { SyncBillingService } from './services/sync-billing'; import { healthRoutes } from './routes/health'; import { createCreditsRoutes } from './routes/credits'; import { createGiftRoutes } from './routes/gifts'; +import { createSyncRoutes } from './routes/sync'; import { createInternalRoutes } from './routes/internal'; import { createWebhookRoutes } from './routes/stripe-webhook'; @@ -30,6 +32,7 @@ const db = getDb(config.databaseUrl); const stripeService = new StripeService(db, config.stripe.secretKey); const creditsService = new CreditsService(db, stripeService); const giftCodeService = new GiftCodeService(db, config.baseUrl); +const syncBillingService = new SyncBillingService(db, creditsService); // ─── App ──────────────────────────────────────────────────── @@ -52,12 +55,18 @@ app.route('/health', healthRoutes); app.use('/api/v1/credits/*', jwtAuth(config.manaAuthUrl)); 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) app.route('/api/v1/gifts', createGiftRoutes(giftCodeService, config.manaAuthUrl)); // Service-to-service routes (X-Service-Key auth) 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) app.route( diff --git a/services/mana-credits/src/lib/validation.ts b/services/mana-credits/src/lib/validation.ts index 16de1aa60..a34495d9e 100644 --- a/services/mana-credits/src/lib/validation.ts +++ b/services/mana-credits/src/lib/validation.ts @@ -37,6 +37,16 @@ export const redeemGiftSchema = z.object({ 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) ────────────────────────── export const internalUseCreditsSchema = z.object({ diff --git a/services/mana-credits/src/routes/internal.ts b/services/mana-credits/src/routes/internal.ts index fbb60c473..be9a89a3b 100644 --- a/services/mana-credits/src/routes/internal.ts +++ b/services/mana-credits/src/routes/internal.ts @@ -5,6 +5,7 @@ import { Hono } from 'hono'; import type { CreditsService } from '../services/credits'; import type { GiftCodeService } from '../services/gift-code'; +import type { SyncBillingService } from '../services/sync-billing'; import { internalUseCreditsSchema, internalRefundSchema, @@ -14,7 +15,8 @@ import { export function createInternalRoutes( creditsService: CreditsService, - giftCodeService: GiftCodeService + giftCodeService: GiftCodeService, + syncBillingService: SyncBillingService ) { return new Hono() .get('/credits/balance/:userId', async (c) => { @@ -47,5 +49,13 @@ export function createInternalRoutes( const body = internalRedeemPendingSchema.parse(await c.req.json()); const result = await giftCodeService.redeemPendingForUser(body.userId, body.email); 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); }); } diff --git a/services/mana-credits/src/routes/sync.ts b/services/mana-credits/src/routes/sync.ts new file mode 100644 index 000000000..288e75435 --- /dev/null +++ b/services/mana-credits/src/routes/sync.ts @@ -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); + }); +} diff --git a/services/mana-credits/src/services/sync-billing.ts b/services/mana-credits/src/services/sync-billing.ts new file mode 100644 index 000000000..93c46f9ae --- /dev/null +++ b/services/mana-credits/src/services/sync-billing.ts @@ -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 = { + 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 }; + } +}