mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21: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 ─────────────────────────────────────
|
// ─── 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;
|
||||||
|
|
||||||
|
|
|
||||||
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,
|
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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
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)
|
// 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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export * from './credits';
|
export * from './credits';
|
||||||
export * from './gifts';
|
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
|
* 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(
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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