mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +02:00
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:
parent
f9b6720d15
commit
5c2ea614cd
16 changed files with 1082 additions and 29 deletions
76
apps/mana/apps/web/src/lib/api/sync.ts
Normal file
76
apps/mana/apps/web/src/lib/api/sync.ts
Normal 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 }),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
68
apps/mana/apps/web/src/lib/stores/sync-billing.svelte.ts
Normal file
68
apps/mana/apps/web/src/lib/stores/sync-billing.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
291
apps/mana/apps/web/src/routes/(app)/settings/sync/+page.svelte
Normal file
291
apps/mana/apps/web/src/routes/(app)/settings/sync/+page.svelte
Normal 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
232
docs/SYNC_BILLING_PLAN.md
Normal 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
|
||||
|
|
@ -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, number> = {
|
|||
[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<CreditOperationType, number> = {
|
|||
*/
|
||||
export enum CreditCategory {
|
||||
AI = 'ai',
|
||||
PRODUCTIVITY = 'productivity',
|
||||
PREMIUM = 'premium',
|
||||
}
|
||||
|
||||
|
|
@ -293,23 +288,11 @@ export const OPERATION_METADATA: Record<CreditOperationType, OperationMetadata>
|
|||
},
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './credits';
|
||||
export * from './gifts';
|
||||
export * from './sync';
|
||||
|
|
|
|||
34
services/mana-credits/src/db/schema/sync.ts
Normal file
34
services/mana-credits/src/db/schema/sync.ts
Normal 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;
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
34
services/mana-credits/src/routes/sync.ts
Normal file
34
services/mana-credits/src/routes/sync.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
235
services/mana-credits/src/services/sync-billing.ts
Normal file
235
services/mana-credits/src/services/sync-billing.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue