mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
e885713fd0
commit
180e07d59e
5 changed files with 159 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
43
services/mana-credits/src/routes/admin.ts
Normal file
43
services/mana-credits/src/routes/admin.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue