feat(credits): admin-gifted sync subscriptions

Admins can now grant Cloud Sync to users without charging credits. Gifted
rows carry is_gifted=true plus gifted_by/gifted_at audit columns; the
billing cron skips them, and /activate and /deactivate refuse to touch
them. New endpoints POST/DELETE /api/v1/admin/sync/:userId/gift.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 14:11:07 +02:00
parent e885713fd0
commit 180e07d59e
5 changed files with 159 additions and 1 deletions

View file

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

View file

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

View file

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

View file

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

View file

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