mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:21:08 +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
|
|
@ -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