feat(credits): add sync billing — monthly credit subscription for cloud sync

Cloud Sync is now a paid feature: 30 credits/month (90/quarter, 360/year).
Users start in local-only mode and opt-in via Settings > Cloud Sync.
1 Credit = 1 Cent, so sync costs ~0.30€/month.

When credits run out, sync is paused (not deleted) and an in-app banner
prompts the user to top up. Local data is always preserved.

Backend (mana-credits):
- New sync_subscriptions table in credits schema
- SyncBillingService with activate/deactivate/chargeRecurring
- User-facing routes: GET/POST /api/v1/sync/{status,activate,deactivate,change-interval}
- Internal routes for server-side checks and cron triggers

Frontend (mana web):
- Sync API client + reactive sync-billing store
- syncEnabled parameter gates createUnifiedSync() — sync only starts when active
- Settings sync page with interval selection and activate/deactivate
- Pause banner in app layout when credits insufficient

Also: removed CALDAV_SYNC/GOOGLE_SYNC operations (not needed),
updated CLOUD_SYNC cost from 5 to 30 credits/month.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-10 22:21:58 +02:00
parent f9b6720d15
commit 5c2ea614cd
16 changed files with 1082 additions and 29 deletions

View file

@ -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.

View file

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

View file

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

View file

@ -2,7 +2,7 @@
* mana-credits Standalone credit management service
*
* 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(

View file

@ -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({

View file

@ -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);
});
}

View file

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

View file

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