diff --git a/services/mana-credits/CLAUDE.md b/services/mana-credits/CLAUDE.md index 47f435e9e..9bf3d7c73 100644 --- a/services/mana-credits/CLAUDE.md +++ b/services/mana-credits/CLAUDE.md @@ -59,6 +59,16 @@ bun run db:studio # Open Drizzle Studio | POST | `/api/v1/gifts/:code/redeem` | Redeem gift (JWT) | | DELETE | `/api/v1/gifts/:id` | Cancel gift (JWT) | +### Admin (JWT auth + `role=admin`) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/admin/sync/:userId` | Get sync status for any user | +| POST | `/api/v1/admin/sync/:userId/gift` | Grant Cloud Sync as a gift (no credits charged, no recurring billing) | +| DELETE | `/api/v1/admin/sync/:userId/gift` | Revoke a sync gift (deactivates sync) | + +Gifted subscriptions have `is_gifted=true` and are skipped by the billing cron — they stay active indefinitely until revoked. The user-facing `/activate` and `/deactivate` endpoints refuse to touch gifted rows. + ### Internal (X-Service-Key auth) | Method | Path | Description | @@ -113,6 +123,8 @@ Cloud Sync is a monthly credit subscription. Users start in local-only mode and 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. +**Gifted sync**: Admins can grant sync via `POST /api/v1/admin/sync/:userId/gift`. Gifted rows (`is_gifted=true`) are immune to the billing cron and never get paused for insufficient credits. Revoke with `DELETE /api/v1/admin/sync/:userId/gift`. + ## Gift Types Two gift types: `simple` (anyone with code can redeem) and `personalized` (auto-redeemed when target email registers). Each gift is single-use. diff --git a/services/mana-credits/src/db/schema/sync.ts b/services/mana-credits/src/db/schema/sync.ts index 5039cc476..ef0caa76e 100644 --- a/services/mana-credits/src/db/schema/sync.ts +++ b/services/mana-credits/src/db/schema/sync.ts @@ -24,6 +24,11 @@ export const syncSubscriptions = creditsSchema.table('sync_subscriptions', { activatedAt: timestamp('activated_at', { withTimezone: true }), nextChargeAt: timestamp('next_charge_at', { withTimezone: true }), pausedAt: timestamp('paused_at', { withTimezone: true }), + // Gift flag: when true, billing cron skips this row and `active` stays on indefinitely. + // Set via admin endpoints; immune to insufficient-credits pauses. + isGifted: boolean('is_gifted').default(false).notNull(), + giftedBy: text('gifted_by'), + giftedAt: timestamp('gifted_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), }); diff --git a/services/mana-credits/src/index.ts b/services/mana-credits/src/index.ts index 234bace45..5316ffed7 100644 --- a/services/mana-credits/src/index.ts +++ b/services/mana-credits/src/index.ts @@ -20,6 +20,7 @@ import { healthRoutes } from './routes/health'; import { createCreditsRoutes } from './routes/credits'; import { createGiftRoutes } from './routes/gifts'; import { createSyncRoutes } from './routes/sync'; +import { createAdminRoutes } from './routes/admin'; import { createInternalRoutes } from './routes/internal'; import { createWebhookRoutes } from './routes/stripe-webhook'; @@ -58,6 +59,10 @@ app.route('/api/v1/credits', createCreditsRoutes(creditsService)); app.use('/api/v1/sync/*', jwtAuth(config.manaAuthUrl)); app.route('/api/v1/sync', createSyncRoutes(syncBillingService)); +// Admin routes (JWT auth + admin role check inside) +app.use('/api/v1/admin/*', jwtAuth(config.manaAuthUrl)); +app.route('/api/v1/admin', createAdminRoutes(syncBillingService)); + // Gift routes (mixed: public GET /:code, JWT for rest) app.route('/api/v1/gifts', createGiftRoutes(giftCodeService, config.manaAuthUrl)); diff --git a/services/mana-credits/src/routes/admin.ts b/services/mana-credits/src/routes/admin.ts new file mode 100644 index 000000000..1695249bb --- /dev/null +++ b/services/mana-credits/src/routes/admin.ts @@ -0,0 +1,43 @@ +/** + * Admin routes — privileged ops (JWT auth + admin role check). + * + * Currently exposes sync-gift management. Extend here as more + * admin-scoped credit operations are needed. + */ + +import { Hono } from 'hono'; +import type { SyncBillingService } from '../services/sync-billing'; +import type { AuthUser } from '../middleware/jwt-auth'; + +export function createAdminRoutes(syncBillingService: SyncBillingService) { + const app = new Hono<{ Variables: { user: AuthUser } }>(); + + app.use('*', async (c, next) => { + const user = c.get('user'); + if (user.role !== 'admin') { + return c.json({ error: 'Forbidden', message: 'Admin access required' }, 403); + } + await next(); + }); + + app.post('/sync/:userId/gift', async (c) => { + const { userId } = c.req.param(); + const admin = c.get('user'); + const result = await syncBillingService.grantSyncGift(userId, admin.userId); + return c.json(result); + }); + + app.delete('/sync/:userId/gift', async (c) => { + const { userId } = c.req.param(); + const result = await syncBillingService.revokeSyncGift(userId); + return c.json(result); + }); + + app.get('/sync/:userId', async (c) => { + const { userId } = c.req.param(); + const status = await syncBillingService.getSyncStatus(userId); + return c.json(status); + }); + + return app; +} diff --git a/services/mana-credits/src/services/sync-billing.ts b/services/mana-credits/src/services/sync-billing.ts index 0fed9bcc2..3a1f32a65 100644 --- a/services/mana-credits/src/services/sync-billing.ts +++ b/services/mana-credits/src/services/sync-billing.ts @@ -79,6 +79,7 @@ export class SyncBillingService { interval: 'monthly' as BillingInterval, nextChargeAt: null, pausedAt: null, + gifted: false, }; } @@ -87,6 +88,7 @@ export class SyncBillingService { interval: sub.billingInterval as BillingInterval, nextChargeAt: sub.nextChargeAt?.toISOString() ?? null, pausedAt: sub.pausedAt?.toISOString() ?? null, + gifted: sub.isGifted, }; } @@ -101,6 +103,10 @@ export class SyncBillingService { .where(eq(syncSubscriptions.userId, userId)) .limit(1); + if (existing?.isGifted) { + throw new BadRequestError('Sync is already gifted — no activation needed'); + } + if (existing?.active) { throw new BadRequestError('Sync is already active'); } @@ -161,6 +167,10 @@ export class SyncBillingService { throw new BadRequestError('Sync is not active'); } + if (sub.isGifted) { + throw new BadRequestError('Sync is gifted — contact support to revoke'); + } + await this.db .update(syncSubscriptions) .set({ @@ -211,10 +221,17 @@ export class SyncBillingService { async chargeRecurring() { const now = new Date(); + // Gifted subscriptions are skipped — they never get charged. const dueSubscriptions = await this.db .select() .from(syncSubscriptions) - .where(and(eq(syncSubscriptions.active, true), lte(syncSubscriptions.nextChargeAt, now))); + .where( + and( + eq(syncSubscriptions.active, true), + eq(syncSubscriptions.isGifted, false), + lte(syncSubscriptions.nextChargeAt, now) + ) + ); let charged = 0; let paused = 0; @@ -260,4 +277,80 @@ export class SyncBillingService { return { charged, paused, errors, total: dueSubscriptions.length }; } + + /** + * Grant sync as a gift — no credits charged, no recurring billing. + * Idempotent: re-granting refreshes the giftedAt/giftedBy fields. + */ + async grantSyncGift(userId: string, grantedBy?: string) { + const now = new Date(); + + const [existing] = await this.db + .select() + .from(syncSubscriptions) + .where(eq(syncSubscriptions.userId, userId)) + .limit(1); + + if (existing) { + await this.db + .update(syncSubscriptions) + .set({ + active: true, + isGifted: true, + giftedBy: grantedBy ?? null, + giftedAt: now, + activatedAt: existing.activatedAt ?? now, + nextChargeAt: null, + pausedAt: null, + updatedAt: now, + }) + .where(eq(syncSubscriptions.userId, userId)); + } else { + await this.db.insert(syncSubscriptions).values({ + userId, + active: true, + isGifted: true, + giftedBy: grantedBy ?? null, + giftedAt: now, + activatedAt: now, + nextChargeAt: null, + }); + } + + return { success: true, userId, gifted: true, active: true }; + } + + /** + * Revoke a sync gift. Deactivates sync and clears the gift flag. + * If the user wants sync back, they must activate normally (paying credits). + */ + async revokeSyncGift(userId: string) { + const [sub] = await this.db + .select() + .from(syncSubscriptions) + .where(eq(syncSubscriptions.userId, userId)) + .limit(1); + + if (!sub) { + throw new NotFoundError('No sync subscription found for user'); + } + + if (!sub.isGifted) { + throw new BadRequestError('Sync is not gifted — nothing to revoke'); + } + + await this.db + .update(syncSubscriptions) + .set({ + active: false, + isGifted: false, + giftedBy: null, + giftedAt: null, + nextChargeAt: null, + updatedAt: new Date(), + }) + .where(eq(syncSubscriptions.userId, userId)); + + return { success: true, userId, gifted: false, active: false }; + } }