mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
refactor(credits): simplify credit system — remove productivity credits, guild pools, complex gift types
The credit system was overengineered for the local-first architecture: - Productivity micro-credits (task/event/contact creation at 0.02 credits) made no sense since these operations happen locally in IndexedDB with zero server cost and were never enforced - Guild pool system (6 DB tables, spending limits, membership checks) had no active users - Gift system had 5 types (simple/personalized/split/first_come/riddle) when 2 suffice Now credits are only charged for operations that actually cost money: AI API calls and premium features (sync, exports). This makes the value proposition clear to users. Changes: - Remove 8 productivity operations + CreditCategory.PRODUCTIVITY from @mana/credits - Delete guild pool service, routes, schema (3 files); remove guild refs from 8 backend files - Simplify gifts to simple + personalized only; remove bcrypt/riddle/portions logic - Update all frontend pages (credits dashboard, gift create/redeem, public gift page) - Update shared-hono consumeCredits() to remove creditSource parameter - Update mana-credits CLAUDE.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
29ad31c4ed
commit
e068335dd4
32 changed files with 143 additions and 922 deletions
|
|
@ -32,24 +32,13 @@ bun run db:studio # Open Drizzle Studio
|
|||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/credits/balance` | Get personal balance |
|
||||
| POST | `/api/v1/credits/use` | Use credits (personal or guild) |
|
||||
| POST | `/api/v1/credits/use` | Use credits |
|
||||
| GET | `/api/v1/credits/transactions` | Transaction history |
|
||||
| GET | `/api/v1/credits/purchases` | Purchase history |
|
||||
| GET | `/api/v1/credits/packages` | Available packages |
|
||||
| POST | `/api/v1/credits/purchase` | Initiate Stripe purchase |
|
||||
| GET | `/api/v1/credits/purchase/:id` | Purchase status |
|
||||
|
||||
### Guild Pool (JWT auth)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/credits/guild/:id/balance` | Pool balance |
|
||||
| POST | `/api/v1/credits/guild/:id/fund` | Fund pool from personal |
|
||||
| POST | `/api/v1/credits/guild/:id/use` | Use from pool |
|
||||
| GET | `/api/v1/credits/guild/:id/transactions` | Pool history |
|
||||
| GET | `/api/v1/credits/guild/:id/members/:uid/limits` | Get limits |
|
||||
| PUT | `/api/v1/credits/guild/:id/members/:uid/limits` | Set limits |
|
||||
|
||||
### Gift Codes (Mixed auth)
|
||||
|
||||
| Method | Path | Description |
|
||||
|
|
@ -70,7 +59,6 @@ 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 |
|
||||
| POST | `/api/v1/internal/guild-pool/init` | Initialize guild pool |
|
||||
|
||||
### Webhooks
|
||||
|
||||
|
|
@ -97,4 +85,16 @@ Own database: `mana_credits`
|
|||
|
||||
Schemas: `credits.*`, `gifts.*`
|
||||
|
||||
Tables: balances, transactions, packages, purchases, usage_stats, stripe_customers, gift_codes, gift_redemptions, guild_pools, guild_transactions, guild_spending_limits
|
||||
Tables: balances, transactions, packages, purchases, usage_stats, stripe_customers, gift_codes, gift_redemptions
|
||||
|
||||
## 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
|
||||
|
||||
Local-first CRUD operations (tasks, events, contacts, etc.) are **free** — they happen in IndexedDB with no server cost.
|
||||
|
||||
## Gift Types
|
||||
|
||||
Two gift types: `simple` (anyone with code can redeem) and `personalized` (auto-redeemed when target email registers). Each gift is single-use.
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ export const transactionTypeEnum = pgEnum('transaction_type', [
|
|||
'usage',
|
||||
'refund',
|
||||
'gift',
|
||||
'guild_funding',
|
||||
]);
|
||||
|
||||
export const transactionStatusEnum = pgEnum('transaction_status', [
|
||||
|
|
@ -73,7 +72,6 @@ export const transactions = creditsSchema.table(
|
|||
description: text('description').notNull(),
|
||||
metadata: jsonb('metadata'),
|
||||
idempotencyKey: text('idempotency_key').unique(),
|
||||
guildId: text('guild_id'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
},
|
||||
|
|
@ -82,7 +80,6 @@ export const transactions = creditsSchema.table(
|
|||
appIdIdx: index('transactions_app_id_idx').on(table.appId),
|
||||
createdAtIdx: index('transactions_created_at_idx').on(table.createdAt),
|
||||
idempotencyKeyIdx: index('transactions_idempotency_key_idx').on(table.idempotencyKey),
|
||||
guildIdIdx: index('transactions_guild_id_idx').on(table.guildId),
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -11,13 +11,7 @@ export const giftsSchema = pgSchema('gifts');
|
|||
|
||||
// ─── Enums ──────────────────────────────────────────────────
|
||||
|
||||
export const giftCodeTypeEnum = pgEnum('gift_code_type', [
|
||||
'simple',
|
||||
'personalized',
|
||||
'split',
|
||||
'first_come',
|
||||
'riddle',
|
||||
]);
|
||||
export const giftCodeTypeEnum = pgEnum('gift_code_type', ['simple', 'personalized']);
|
||||
|
||||
export const giftCodeStatusEnum = pgEnum('gift_code_status', [
|
||||
'active',
|
||||
|
|
@ -29,7 +23,6 @@ export const giftCodeStatusEnum = pgEnum('gift_code_status', [
|
|||
|
||||
export const giftRedemptionStatusEnum = pgEnum('gift_redemption_status', [
|
||||
'success',
|
||||
'failed_wrong_answer',
|
||||
'failed_wrong_user',
|
||||
'failed_depleted',
|
||||
'failed_expired',
|
||||
|
|
@ -51,9 +44,7 @@ export const giftCodes = giftsSchema.table(
|
|||
|
||||
// Credit allocation
|
||||
totalCredits: integer('total_credits').notNull(),
|
||||
creditsPerPortion: integer('credits_per_portion').notNull(),
|
||||
totalPortions: integer('total_portions').notNull().default(1),
|
||||
claimedPortions: integer('claimed_portions').notNull().default(0),
|
||||
redeemed: integer('redeemed').notNull().default(0), // 0 = unclaimed, 1 = claimed
|
||||
|
||||
// Type and status
|
||||
type: giftCodeTypeEnum('type').notNull().default('simple'),
|
||||
|
|
@ -62,10 +53,6 @@ export const giftCodes = giftsSchema.table(
|
|||
// Personalization
|
||||
targetEmail: text('target_email'),
|
||||
|
||||
// Riddle
|
||||
riddleQuestion: text('riddle_question'),
|
||||
riddleAnswerHash: text('riddle_answer_hash'),
|
||||
|
||||
// Message
|
||||
message: text('message'),
|
||||
|
||||
|
|
@ -98,7 +85,6 @@ export const giftRedemptions = giftsSchema.table(
|
|||
|
||||
status: giftRedemptionStatusEnum('status').notNull(),
|
||||
creditsReceived: integer('credits_received').notNull().default(0),
|
||||
portionNumber: integer('portion_number'),
|
||||
|
||||
creditTransactionId: uuid('credit_transaction_id'),
|
||||
sourceAppId: text('source_app_id'),
|
||||
|
|
@ -127,8 +113,6 @@ export const GIFT_CODE_LENGTH = 6;
|
|||
export const GIFT_CODE_RULES = {
|
||||
minCredits: 1,
|
||||
maxCredits: 10000,
|
||||
maxPortions: 100,
|
||||
maxMessageLength: 500,
|
||||
maxRiddleQuestionLength: 200,
|
||||
defaultExpirationDays: 90,
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
/**
|
||||
* Guild Pool Schema — Shared Mana pools for organizations
|
||||
*
|
||||
* Adapted from mana-auth: removed FK references to auth.users and organizations.
|
||||
* Organization/user IDs remain as text columns without FK constraints.
|
||||
*/
|
||||
|
||||
import { uuid, integer, text, timestamp, jsonb, index, unique } from 'drizzle-orm/pg-core';
|
||||
import { creditsSchema } from './credits';
|
||||
|
||||
/** Guild Mana pool (one per organization) */
|
||||
export const guildPools = creditsSchema.table('guild_pools', {
|
||||
organizationId: text('organization_id').primaryKey(),
|
||||
balance: integer('balance').default(0).notNull(),
|
||||
totalFunded: integer('total_funded').default(0).notNull(),
|
||||
totalSpent: integer('total_spent').default(0).notNull(),
|
||||
version: integer('version').default(0).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
/** Optional per-member spending limits */
|
||||
export const guildSpendingLimits = creditsSchema.table(
|
||||
'guild_spending_limits',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
userId: text('user_id').notNull(),
|
||||
dailyLimit: integer('daily_limit'),
|
||||
monthlyLimit: integer('monthly_limit'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
orgUserUnique: unique('guild_spending_limits_org_user_unique').on(
|
||||
table.organizationId,
|
||||
table.userId
|
||||
),
|
||||
organizationIdIdx: index('guild_spending_limits_org_id_idx').on(table.organizationId),
|
||||
userIdIdx: index('guild_spending_limits_user_id_idx').on(table.userId),
|
||||
})
|
||||
);
|
||||
|
||||
/** Immutable transaction ledger for guild pool */
|
||||
export const guildTransactions = creditsSchema.table(
|
||||
'guild_transactions',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
organizationId: text('organization_id').notNull(),
|
||||
userId: text('user_id').notNull(),
|
||||
type: text('type').notNull(), // 'funding', 'usage', 'refund'
|
||||
amount: integer('amount').notNull(),
|
||||
balanceBefore: integer('balance_before').notNull(),
|
||||
balanceAfter: integer('balance_after').notNull(),
|
||||
appId: text('app_id'),
|
||||
description: text('description').notNull(),
|
||||
metadata: jsonb('metadata'),
|
||||
idempotencyKey: text('idempotency_key').unique(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
organizationIdIdx: index('guild_transactions_org_id_idx').on(table.organizationId),
|
||||
userIdIdx: index('guild_transactions_user_id_idx').on(table.userId),
|
||||
createdAtIdx: index('guild_transactions_created_at_idx').on(table.createdAt),
|
||||
idempotencyKeyIdx: index('guild_transactions_idempotency_key_idx').on(table.idempotencyKey),
|
||||
orgUserCreatedIdx: index('guild_transactions_org_user_created_idx').on(
|
||||
table.organizationId,
|
||||
table.userId,
|
||||
table.createdAt
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
// ─── Type Exports ───────────────────────────────────────────
|
||||
|
||||
export type GuildPool = typeof guildPools.$inferSelect;
|
||||
export type GuildTransaction = typeof guildTransactions.$inferSelect;
|
||||
export type GuildSpendingLimit = typeof guildSpendingLimits.$inferSelect;
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
export * from './credits';
|
||||
export * from './gifts';
|
||||
export * from './guilds';
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* mana-credits — Standalone credit management service
|
||||
*
|
||||
* Hono + Bun runtime. Extracted from mana-auth.
|
||||
* Handles: personal credits, guild pools, gift codes, Stripe payments.
|
||||
* Handles: personal credits, gift codes, Stripe payments.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
|
|
@ -13,12 +13,10 @@ import { serviceErrorHandler as errorHandler } from '@mana/shared-hono';
|
|||
import { jwtAuth } from './middleware/jwt-auth';
|
||||
import { serviceAuth } from './middleware/service-auth';
|
||||
import { StripeService } from './services/stripe';
|
||||
import { GuildPoolService } from './services/guild-pool';
|
||||
import { CreditsService } from './services/credits';
|
||||
import { GiftCodeService } from './services/gift-code';
|
||||
import { healthRoutes } from './routes/health';
|
||||
import { createCreditsRoutes } from './routes/credits';
|
||||
import { createGuildRoutes } from './routes/guild';
|
||||
import { createGiftRoutes } from './routes/gifts';
|
||||
import { createInternalRoutes } from './routes/internal';
|
||||
import { createWebhookRoutes } from './routes/stripe-webhook';
|
||||
|
|
@ -30,8 +28,7 @@ const db = getDb(config.databaseUrl);
|
|||
|
||||
// Instantiate services (manual DI — no NestJS)
|
||||
const stripeService = new StripeService(db, config.stripe.secretKey);
|
||||
const guildPoolService = new GuildPoolService(db, config.manaAuthUrl, config.serviceKey);
|
||||
const creditsService = new CreditsService(db, stripeService, guildPoolService);
|
||||
const creditsService = new CreditsService(db, stripeService);
|
||||
const giftCodeService = new GiftCodeService(db, config.baseUrl);
|
||||
|
||||
// ─── App ────────────────────────────────────────────────────
|
||||
|
|
@ -54,17 +51,13 @@ app.route('/health', healthRoutes);
|
|||
// User-facing routes (JWT auth)
|
||||
app.use('/api/v1/credits/*', jwtAuth(config.manaAuthUrl));
|
||||
app.route('/api/v1/credits', createCreditsRoutes(creditsService));
|
||||
app.route('/api/v1/credits/guild', createGuildRoutes(guildPoolService));
|
||||
|
||||
// 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, guildPoolService)
|
||||
);
|
||||
app.route('/api/v1/internal', createInternalRoutes(creditsService, giftCodeService));
|
||||
|
||||
// Stripe webhooks (verified by signature, no auth middleware)
|
||||
app.route(
|
||||
|
|
|
|||
|
|
@ -10,12 +10,6 @@ export const useCreditsSchema = z.object({
|
|||
amount: z.number().positive(),
|
||||
appId: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
creditSource: z
|
||||
.object({
|
||||
type: z.literal('guild'),
|
||||
guildId: z.string().min(1),
|
||||
})
|
||||
.optional(),
|
||||
idempotencyKey: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
|
@ -29,33 +23,17 @@ export const createPaymentLinkSchema = z.object({
|
|||
quantity: z.number().int().positive().default(1),
|
||||
});
|
||||
|
||||
// ─── Guild ──────────────────────────────────────────────────
|
||||
|
||||
export const fundGuildPoolSchema = z.object({
|
||||
amount: z.number().positive(),
|
||||
idempotencyKey: z.string().optional(),
|
||||
});
|
||||
|
||||
export const setSpendingLimitSchema = z.object({
|
||||
dailyLimit: z.number().int().positive().nullable().optional(),
|
||||
monthlyLimit: z.number().int().positive().nullable().optional(),
|
||||
});
|
||||
|
||||
// ─── Gifts ──────────────────────────────────────────────────
|
||||
|
||||
export const createGiftSchema = z.object({
|
||||
totalCredits: z.number().int().positive().min(1).max(10000),
|
||||
type: z.enum(['simple', 'personalized', 'split', 'first_come', 'riddle']).default('simple'),
|
||||
totalPortions: z.number().int().positive().max(100).default(1),
|
||||
type: z.enum(['simple', 'personalized']).default('simple'),
|
||||
targetEmail: z.string().email().optional(),
|
||||
riddleQuestion: z.string().max(200).optional(),
|
||||
riddleAnswer: z.string().optional(),
|
||||
message: z.string().max(500).optional(),
|
||||
expirationDays: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export const redeemGiftSchema = z.object({
|
||||
riddleAnswer: z.string().optional(),
|
||||
sourceAppId: z.string().optional(),
|
||||
});
|
||||
|
||||
|
|
@ -66,12 +44,6 @@ export const internalUseCreditsSchema = z.object({
|
|||
amount: z.number().positive(),
|
||||
appId: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
creditSource: z
|
||||
.object({
|
||||
type: z.literal('guild'),
|
||||
guildId: z.string().min(1),
|
||||
})
|
||||
.optional(),
|
||||
idempotencyKey: z.string().optional(),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export function createCreditsRoutes(creditsService: CreditsService) {
|
|||
.post('/use', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = useCreditsSchema.parse(await c.req.json());
|
||||
const result = await creditsService.useCreditsWithSource(user.userId, body);
|
||||
const result = await creditsService.useCredits(user.userId, body);
|
||||
return c.json(result);
|
||||
})
|
||||
.get('/transactions', async (c) => {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ export function createGiftRoutes(giftCodeService: GiftCodeService, authUrl: stri
|
|||
const result = await giftCodeService.redeemGift(
|
||||
c.req.param('code'),
|
||||
user.userId,
|
||||
body.riddleAnswer,
|
||||
body.sourceAppId
|
||||
);
|
||||
return c.json(result);
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
/**
|
||||
* Guild pool routes — shared credit pool endpoints (JWT auth)
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { GuildPoolService } from '../services/guild-pool';
|
||||
import type { AuthUser } from '../middleware/jwt-auth';
|
||||
import { fundGuildPoolSchema, setSpendingLimitSchema } from '../lib/validation';
|
||||
|
||||
export function createGuildRoutes(guildPoolService: GuildPoolService) {
|
||||
return new Hono<{ Variables: { user: AuthUser } }>()
|
||||
.get('/:guildId/balance', async (c) => {
|
||||
const user = c.get('user');
|
||||
const balance = await guildPoolService.getBalance(c.req.param('guildId'), user.userId);
|
||||
return c.json(balance);
|
||||
})
|
||||
.post('/:guildId/fund', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = fundGuildPoolSchema.parse(await c.req.json());
|
||||
const result = await guildPoolService.fundPool(
|
||||
c.req.param('guildId'),
|
||||
user.userId,
|
||||
body.amount,
|
||||
body.idempotencyKey
|
||||
);
|
||||
return c.json(result);
|
||||
})
|
||||
.post('/:guildId/use', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = await c.req.json();
|
||||
const result = await guildPoolService.useGuildCredits(
|
||||
c.req.param('guildId'),
|
||||
user.userId,
|
||||
body
|
||||
);
|
||||
return c.json(result);
|
||||
})
|
||||
.get('/:guildId/transactions', async (c) => {
|
||||
const user = c.get('user');
|
||||
const limit = parseInt(c.req.query('limit') || '50', 10);
|
||||
const offset = parseInt(c.req.query('offset') || '0', 10);
|
||||
const txs = await guildPoolService.getTransactions(
|
||||
c.req.param('guildId'),
|
||||
user.userId,
|
||||
limit,
|
||||
offset
|
||||
);
|
||||
return c.json(txs);
|
||||
})
|
||||
.get('/:guildId/members/:userId/limits', async (c) => {
|
||||
const limits = await guildPoolService.getSpendingLimits(
|
||||
c.req.param('guildId'),
|
||||
c.req.param('userId')
|
||||
);
|
||||
return c.json(limits);
|
||||
})
|
||||
.put('/:guildId/members/:userId/limits', async (c) => {
|
||||
const user = c.get('user');
|
||||
const body = setSpendingLimitSchema.parse(await c.req.json());
|
||||
const result = await guildPoolService.setSpendingLimits(
|
||||
c.req.param('guildId'),
|
||||
c.req.param('userId'),
|
||||
user.userId,
|
||||
body.dailyLimit ?? null,
|
||||
body.monthlyLimit ?? null
|
||||
);
|
||||
return c.json(result);
|
||||
});
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@
|
|||
import { Hono } from 'hono';
|
||||
import type { CreditsService } from '../services/credits';
|
||||
import type { GiftCodeService } from '../services/gift-code';
|
||||
import type { GuildPoolService } from '../services/guild-pool';
|
||||
import {
|
||||
internalUseCreditsSchema,
|
||||
internalRefundSchema,
|
||||
|
|
@ -15,8 +14,7 @@ import {
|
|||
|
||||
export function createInternalRoutes(
|
||||
creditsService: CreditsService,
|
||||
giftCodeService: GiftCodeService,
|
||||
guildPoolService: GuildPoolService
|
||||
giftCodeService: GiftCodeService
|
||||
) {
|
||||
return new Hono()
|
||||
.get('/credits/balance/:userId', async (c) => {
|
||||
|
|
@ -26,7 +24,7 @@ export function createInternalRoutes(
|
|||
.post('/credits/use', async (c) => {
|
||||
const body = internalUseCreditsSchema.parse(await c.req.json());
|
||||
const { userId, ...params } = body;
|
||||
const result = await creditsService.useCreditsWithSource(userId, params);
|
||||
const result = await creditsService.useCredits(userId, params);
|
||||
return c.json(result);
|
||||
})
|
||||
.post('/credits/refund', async (c) => {
|
||||
|
|
@ -49,10 +47,5 @@ export function createInternalRoutes(
|
|||
const body = internalRedeemPendingSchema.parse(await c.req.json());
|
||||
const result = await giftCodeService.redeemPendingForUser(body.userId, body.email);
|
||||
return c.json(result);
|
||||
})
|
||||
.post('/guild-pool/init', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const pool = await guildPoolService.initializePool(body.organizationId);
|
||||
return c.json(pool);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { eq, and, desc } from 'drizzle-orm';
|
|||
import { balances, transactions, purchases, packages, usageStats } from '../db/schema/credits';
|
||||
import type { Database } from '../db/connection';
|
||||
import type { StripeService } from './stripe';
|
||||
import type { GuildPoolService } from './guild-pool';
|
||||
import {
|
||||
BadRequestError,
|
||||
NotFoundError,
|
||||
|
|
@ -21,7 +20,6 @@ interface UseCreditsParams {
|
|||
amount: number;
|
||||
appId: string;
|
||||
description: string;
|
||||
creditSource?: { type: 'guild'; guildId: string };
|
||||
idempotencyKey?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
|
@ -29,8 +27,7 @@ interface UseCreditsParams {
|
|||
export class CreditsService {
|
||||
constructor(
|
||||
private db: Database,
|
||||
private stripeService: StripeService,
|
||||
private guildPoolService: GuildPoolService
|
||||
private stripeService: StripeService
|
||||
) {}
|
||||
|
||||
async initializeBalance(userId: string) {
|
||||
|
|
@ -150,14 +147,6 @@ export class CreditsService {
|
|||
});
|
||||
}
|
||||
|
||||
/** Route to personal or guild pool based on creditSource */
|
||||
async useCreditsWithSource(userId: string, params: UseCreditsParams) {
|
||||
if (params.creditSource?.type === 'guild' && params.creditSource.guildId) {
|
||||
return this.guildPoolService.useGuildCredits(params.creditSource.guildId, userId, params);
|
||||
}
|
||||
return this.useCredits(userId, params);
|
||||
}
|
||||
|
||||
async refundCredits(
|
||||
userId: string,
|
||||
amount: number,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
/**
|
||||
* Gift Code Service — Gift code generation, redemption, cancellation
|
||||
*
|
||||
* Ported from mana-auth GiftCodeService.
|
||||
* Simplified: only 'simple' and 'personalized' gift types.
|
||||
* Each gift is a single-use code (one redeemer gets all credits).
|
||||
*/
|
||||
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import {
|
||||
giftCodes,
|
||||
giftRedemptions,
|
||||
|
|
@ -19,11 +19,8 @@ import { BadRequestError, NotFoundError } from '../lib/errors';
|
|||
|
||||
interface CreateGiftParams {
|
||||
totalCredits: number;
|
||||
type?: 'simple' | 'personalized' | 'split' | 'first_come' | 'riddle';
|
||||
totalPortions?: number;
|
||||
type?: 'simple' | 'personalized';
|
||||
targetEmail?: string;
|
||||
riddleQuestion?: string;
|
||||
riddleAnswer?: string;
|
||||
message?: string;
|
||||
expirationDays?: number;
|
||||
}
|
||||
|
|
@ -43,7 +40,7 @@ export class GiftCodeService {
|
|||
}
|
||||
|
||||
async createGift(creatorId: string, creatorName: string, params: CreateGiftParams) {
|
||||
const { totalCredits, type = 'simple', totalPortions = 1 } = params;
|
||||
const { totalCredits, type = 'simple' } = params;
|
||||
|
||||
if (totalCredits < GIFT_CODE_RULES.minCredits || totalCredits > GIFT_CODE_RULES.maxCredits) {
|
||||
throw new BadRequestError(
|
||||
|
|
@ -51,10 +48,6 @@ export class GiftCodeService {
|
|||
);
|
||||
}
|
||||
|
||||
const creditsPerPortion = Math.floor(totalCredits / totalPortions);
|
||||
if (creditsPerPortion < 1) throw new BadRequestError('Credits per portion must be at least 1');
|
||||
|
||||
// Reserve credits from creator's balance
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const [balance] = await tx
|
||||
.select()
|
||||
|
|
@ -94,12 +87,6 @@ export class GiftCodeService {
|
|||
})
|
||||
.returning();
|
||||
|
||||
// Hash riddle answer if present
|
||||
let riddleAnswerHash: string | undefined;
|
||||
if (params.riddleAnswer) {
|
||||
riddleAnswerHash = await bcrypt.hash(params.riddleAnswer.toLowerCase().trim(), 10);
|
||||
}
|
||||
|
||||
const code = this.generateCode();
|
||||
const expiresAt = params.expirationDays
|
||||
? new Date(Date.now() + params.expirationDays * 24 * 60 * 60 * 1000)
|
||||
|
|
@ -113,12 +100,8 @@ export class GiftCodeService {
|
|||
creatorId,
|
||||
creatorName,
|
||||
totalCredits,
|
||||
creditsPerPortion,
|
||||
totalPortions,
|
||||
type,
|
||||
targetEmail: params.targetEmail,
|
||||
riddleQuestion: params.riddleQuestion,
|
||||
riddleAnswerHash,
|
||||
message: params.message,
|
||||
expiresAt,
|
||||
reservationTransactionId: reservationTx.id,
|
||||
|
|
@ -129,7 +112,7 @@ export class GiftCodeService {
|
|||
});
|
||||
}
|
||||
|
||||
async redeemGift(code: string, redeemerId: string, riddleAnswer?: string, sourceAppId?: string) {
|
||||
async redeemGift(code: string, redeemerId: string, sourceAppId?: string) {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const [gift] = await tx
|
||||
.select()
|
||||
|
|
@ -142,32 +125,7 @@ export class GiftCodeService {
|
|||
if (gift.status !== 'active') throw new BadRequestError(`Gift code is ${gift.status}`);
|
||||
if (gift.expiresAt && new Date() > gift.expiresAt)
|
||||
throw new BadRequestError('Gift code expired');
|
||||
if (gift.claimedPortions >= gift.totalPortions)
|
||||
throw new BadRequestError('Gift fully claimed');
|
||||
|
||||
// Personalization check
|
||||
if (gift.type === 'personalized' && gift.targetEmail) {
|
||||
// Caller must verify email matches — for now we allow all
|
||||
}
|
||||
|
||||
// Riddle check
|
||||
if (gift.type === 'riddle' && gift.riddleAnswerHash) {
|
||||
if (!riddleAnswer) throw new BadRequestError('Riddle answer required');
|
||||
const correct = await bcrypt.compare(
|
||||
riddleAnswer.toLowerCase().trim(),
|
||||
gift.riddleAnswerHash
|
||||
);
|
||||
if (!correct) {
|
||||
await tx.insert(giftRedemptions).values({
|
||||
giftCodeId: gift.id,
|
||||
redeemerUserId: redeemerId,
|
||||
status: 'failed_wrong_answer',
|
||||
creditsReceived: 0,
|
||||
sourceAppId,
|
||||
});
|
||||
throw new BadRequestError('Wrong riddle answer');
|
||||
}
|
||||
}
|
||||
if (gift.redeemed >= 1) throw new BadRequestError('Gift already claimed');
|
||||
|
||||
// Add credits to redeemer
|
||||
const [balance] = await tx
|
||||
|
|
@ -178,14 +136,14 @@ export class GiftCodeService {
|
|||
.limit(1);
|
||||
|
||||
const balanceBefore = balance?.balance ?? 0;
|
||||
const newBalance = balanceBefore + gift.creditsPerPortion;
|
||||
const newBalance = balanceBefore + gift.totalCredits;
|
||||
|
||||
if (balance) {
|
||||
await tx
|
||||
.update(balances)
|
||||
.set({
|
||||
balance: newBalance,
|
||||
totalEarned: balance.totalEarned + gift.creditsPerPortion,
|
||||
totalEarned: balance.totalEarned + gift.totalCredits,
|
||||
version: balance.version + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
|
|
@ -193,8 +151,8 @@ export class GiftCodeService {
|
|||
} else {
|
||||
await tx.insert(balances).values({
|
||||
userId: redeemerId,
|
||||
balance: gift.creditsPerPortion,
|
||||
totalEarned: gift.creditsPerPortion,
|
||||
balance: gift.totalCredits,
|
||||
totalEarned: gift.totalCredits,
|
||||
totalSpent: 0,
|
||||
});
|
||||
}
|
||||
|
|
@ -206,11 +164,11 @@ export class GiftCodeService {
|
|||
userId: redeemerId,
|
||||
type: 'gift',
|
||||
status: 'completed',
|
||||
amount: gift.creditsPerPortion,
|
||||
amount: gift.totalCredits,
|
||||
balanceBefore,
|
||||
balanceAfter: newBalance,
|
||||
appId: 'gifts',
|
||||
description: `Gift redeemed: ${gift.creditsPerPortion} credits from ${gift.creatorName || 'someone'}`,
|
||||
description: `Gift redeemed: ${gift.totalCredits} credits from ${gift.creatorName || 'someone'}`,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
|
@ -220,23 +178,20 @@ export class GiftCodeService {
|
|||
giftCodeId: gift.id,
|
||||
redeemerUserId: redeemerId,
|
||||
status: 'success',
|
||||
creditsReceived: gift.creditsPerPortion,
|
||||
portionNumber: gift.claimedPortions + 1,
|
||||
creditsReceived: gift.totalCredits,
|
||||
creditTransactionId: creditTx.id,
|
||||
sourceAppId,
|
||||
});
|
||||
|
||||
// Update gift
|
||||
const newClaimedPortions = gift.claimedPortions + 1;
|
||||
const newStatus = newClaimedPortions >= gift.totalPortions ? 'depleted' : 'active';
|
||||
// Mark gift as depleted
|
||||
await tx
|
||||
.update(giftCodes)
|
||||
.set({ claimedPortions: newClaimedPortions, status: newStatus, updatedAt: new Date() })
|
||||
.set({ redeemed: 1, status: 'depleted', updatedAt: new Date() })
|
||||
.where(eq(giftCodes.id, gift.id));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
creditsReceived: gift.creditsPerPortion,
|
||||
creditsReceived: gift.totalCredits,
|
||||
message: gift.message,
|
||||
creatorName: gift.creatorName,
|
||||
};
|
||||
|
|
@ -256,10 +211,8 @@ export class GiftCodeService {
|
|||
code: gift.code,
|
||||
type: gift.type,
|
||||
status: gift.status,
|
||||
creditsPerPortion: gift.creditsPerPortion,
|
||||
totalPortions: gift.totalPortions,
|
||||
claimedPortions: gift.claimedPortions,
|
||||
riddleQuestion: gift.riddleQuestion,
|
||||
totalCredits: gift.totalCredits,
|
||||
redeemed: gift.redeemed > 0,
|
||||
message: gift.message,
|
||||
creatorName: gift.creatorName,
|
||||
expiresAt: gift.expiresAt,
|
||||
|
|
@ -294,7 +247,8 @@ export class GiftCodeService {
|
|||
if (!gift) throw new NotFoundError('Gift not found');
|
||||
if (gift.status !== 'active') throw new BadRequestError('Only active gifts can be cancelled');
|
||||
|
||||
const refundAmount = (gift.totalPortions - gift.claimedPortions) * gift.creditsPerPortion;
|
||||
// Only refund if not yet redeemed
|
||||
const refundAmount = gift.redeemed === 0 ? gift.totalCredits : 0;
|
||||
|
||||
if (refundAmount > 0) {
|
||||
const [balance] = await tx
|
||||
|
|
@ -354,7 +308,7 @@ export class GiftCodeService {
|
|||
let totalRedeemed = 0;
|
||||
for (const gift of pendingGifts) {
|
||||
try {
|
||||
const result = await this.redeemGift(gift.code, userId, undefined, 'auto-registration');
|
||||
const result = await this.redeemGift(gift.code, userId, 'auto-registration');
|
||||
totalRedeemed += result.creditsReceived;
|
||||
} catch {
|
||||
// Skip failed redemptions
|
||||
|
|
|
|||
|
|
@ -1,316 +0,0 @@
|
|||
/**
|
||||
* Guild Pool Service — Shared organization credit pools
|
||||
*
|
||||
* Ported from mana-auth GuildPoolService.
|
||||
* Membership checks via HTTP call to mana-auth (separate DB).
|
||||
*/
|
||||
|
||||
import { eq, and, desc, gte, sql } from 'drizzle-orm';
|
||||
import { guildPools, guildTransactions, guildSpendingLimits } from '../db/schema/guilds';
|
||||
import type { Database } from '../db/connection';
|
||||
import {
|
||||
BadRequestError,
|
||||
NotFoundError,
|
||||
ForbiddenError,
|
||||
InsufficientCreditsError,
|
||||
} from '../lib/errors';
|
||||
|
||||
interface UseGuildCreditsParams {
|
||||
amount: number;
|
||||
appId: string;
|
||||
description: string;
|
||||
idempotencyKey?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class GuildPoolService {
|
||||
constructor(
|
||||
private db: Database,
|
||||
private authUrl: string,
|
||||
private serviceKey: string
|
||||
) {}
|
||||
|
||||
/** Verify guild membership via mana-auth internal API */
|
||||
private async verifyMembership(
|
||||
guildId: string,
|
||||
userId: string
|
||||
): Promise<{ isMember: boolean; role: string }> {
|
||||
try {
|
||||
const res = await fetch(`${this.authUrl}/api/v1/internal/org/${guildId}/member/${userId}`, {
|
||||
headers: { 'X-Service-Key': this.serviceKey },
|
||||
});
|
||||
if (!res.ok) return { isMember: false, role: '' };
|
||||
return await res.json();
|
||||
} catch {
|
||||
return { isMember: false, role: '' };
|
||||
}
|
||||
}
|
||||
|
||||
async initializePool(organizationId: string) {
|
||||
const [existing] = await this.db
|
||||
.select()
|
||||
.from(guildPools)
|
||||
.where(eq(guildPools.organizationId, organizationId))
|
||||
.limit(1);
|
||||
|
||||
if (existing) return existing;
|
||||
|
||||
const [pool] = await this.db
|
||||
.insert(guildPools)
|
||||
.values({ organizationId, balance: 0, totalFunded: 0, totalSpent: 0 })
|
||||
.returning();
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
async getBalance(guildId: string, userId: string) {
|
||||
const membership = await this.verifyMembership(guildId, userId);
|
||||
if (!membership.isMember) throw new ForbiddenError('Not a guild member');
|
||||
|
||||
const [pool] = await this.db
|
||||
.select()
|
||||
.from(guildPools)
|
||||
.where(eq(guildPools.organizationId, guildId))
|
||||
.limit(1);
|
||||
|
||||
if (!pool) throw new NotFoundError('Guild pool not found');
|
||||
|
||||
return { balance: pool.balance, totalFunded: pool.totalFunded, totalSpent: pool.totalSpent };
|
||||
}
|
||||
|
||||
async useGuildCredits(guildId: string, userId: string, params: UseGuildCreditsParams) {
|
||||
const membership = await this.verifyMembership(guildId, userId);
|
||||
if (!membership.isMember) throw new ForbiddenError('Not a guild member');
|
||||
|
||||
// Check spending limits
|
||||
await this.checkSpendingLimits(guildId, userId, params.amount);
|
||||
|
||||
// Idempotency
|
||||
if (params.idempotencyKey) {
|
||||
const [existing] = await this.db
|
||||
.select()
|
||||
.from(guildTransactions)
|
||||
.where(eq(guildTransactions.idempotencyKey, params.idempotencyKey))
|
||||
.limit(1);
|
||||
if (existing) return { success: true, transaction: existing };
|
||||
}
|
||||
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const [pool] = await tx
|
||||
.select()
|
||||
.from(guildPools)
|
||||
.where(eq(guildPools.organizationId, guildId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!pool) throw new NotFoundError('Guild pool not found');
|
||||
if (pool.balance < params.amount) {
|
||||
throw new InsufficientCreditsError(params.amount, pool.balance);
|
||||
}
|
||||
|
||||
const newBalance = pool.balance - params.amount;
|
||||
|
||||
await tx
|
||||
.update(guildPools)
|
||||
.set({
|
||||
balance: newBalance,
|
||||
totalSpent: pool.totalSpent + params.amount,
|
||||
version: pool.version + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(guildPools.organizationId, guildId), eq(guildPools.version, pool.version)));
|
||||
|
||||
const [transaction] = await tx
|
||||
.insert(guildTransactions)
|
||||
.values({
|
||||
organizationId: guildId,
|
||||
userId,
|
||||
type: 'usage',
|
||||
amount: -params.amount,
|
||||
balanceBefore: pool.balance,
|
||||
balanceAfter: newBalance,
|
||||
appId: params.appId,
|
||||
description: params.description,
|
||||
metadata: params.metadata,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return { success: true, transaction, newBalance: { balance: newBalance } };
|
||||
});
|
||||
}
|
||||
|
||||
async fundPool(guildId: string, funderId: string, amount: number, idempotencyKey?: string) {
|
||||
const membership = await this.verifyMembership(guildId, funderId);
|
||||
if (!membership.isMember || !['owner', 'admin'].includes(membership.role)) {
|
||||
throw new ForbiddenError('Only owners and admins can fund the pool');
|
||||
}
|
||||
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const [pool] = await tx
|
||||
.select()
|
||||
.from(guildPools)
|
||||
.where(eq(guildPools.organizationId, guildId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
if (!pool) throw new NotFoundError('Guild pool not found');
|
||||
|
||||
const newBalance = pool.balance + amount;
|
||||
|
||||
await tx
|
||||
.update(guildPools)
|
||||
.set({
|
||||
balance: newBalance,
|
||||
totalFunded: pool.totalFunded + amount,
|
||||
version: pool.version + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(guildPools.organizationId, guildId));
|
||||
|
||||
const [transaction] = await tx
|
||||
.insert(guildTransactions)
|
||||
.values({
|
||||
organizationId: guildId,
|
||||
userId: funderId,
|
||||
type: 'funding',
|
||||
amount,
|
||||
balanceBefore: pool.balance,
|
||||
balanceAfter: newBalance,
|
||||
description: `Pool funded with ${amount} credits`,
|
||||
idempotencyKey,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return { success: true, transaction, newBalance: { balance: newBalance } };
|
||||
});
|
||||
}
|
||||
|
||||
async getTransactions(guildId: string, userId: string, limit = 50, offset = 0) {
|
||||
const membership = await this.verifyMembership(guildId, userId);
|
||||
if (!membership.isMember) throw new ForbiddenError('Not a guild member');
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(guildTransactions)
|
||||
.where(eq(guildTransactions.organizationId, guildId))
|
||||
.orderBy(desc(guildTransactions.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
async getSpendingLimits(guildId: string, userId: string) {
|
||||
const [limit] = await this.db
|
||||
.select()
|
||||
.from(guildSpendingLimits)
|
||||
.where(
|
||||
and(eq(guildSpendingLimits.organizationId, guildId), eq(guildSpendingLimits.userId, userId))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return limit || { dailyLimit: null, monthlyLimit: null };
|
||||
}
|
||||
|
||||
async setSpendingLimits(
|
||||
guildId: string,
|
||||
targetUserId: string,
|
||||
setterId: string,
|
||||
dailyLimit: number | null,
|
||||
monthlyLimit: number | null
|
||||
) {
|
||||
const membership = await this.verifyMembership(guildId, setterId);
|
||||
if (!membership.isMember || !['owner', 'admin'].includes(membership.role)) {
|
||||
throw new ForbiddenError('Only owners and admins can set spending limits');
|
||||
}
|
||||
|
||||
const [existing] = await this.db
|
||||
.select()
|
||||
.from(guildSpendingLimits)
|
||||
.where(
|
||||
and(
|
||||
eq(guildSpendingLimits.organizationId, guildId),
|
||||
eq(guildSpendingLimits.userId, targetUserId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
await this.db
|
||||
.update(guildSpendingLimits)
|
||||
.set({ dailyLimit, monthlyLimit, updatedAt: new Date() })
|
||||
.where(eq(guildSpendingLimits.id, existing.id));
|
||||
} else {
|
||||
await this.db.insert(guildSpendingLimits).values({
|
||||
organizationId: guildId,
|
||||
userId: targetUserId,
|
||||
dailyLimit,
|
||||
monthlyLimit,
|
||||
});
|
||||
}
|
||||
|
||||
return { dailyLimit, monthlyLimit };
|
||||
}
|
||||
|
||||
private async checkSpendingLimits(guildId: string, userId: string, amount: number) {
|
||||
const [limit] = await this.db
|
||||
.select()
|
||||
.from(guildSpendingLimits)
|
||||
.where(
|
||||
and(eq(guildSpendingLimits.organizationId, guildId), eq(guildSpendingLimits.userId, userId))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!limit) return; // No limits set
|
||||
|
||||
const now = new Date();
|
||||
|
||||
if (limit.dailyLimit !== null) {
|
||||
const dayStart = new Date(now);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const dailySpent = await this.db
|
||||
.select({ total: sql<number>`COALESCE(SUM(ABS(${guildTransactions.amount})), 0)` })
|
||||
.from(guildTransactions)
|
||||
.where(
|
||||
and(
|
||||
eq(guildTransactions.organizationId, guildId),
|
||||
eq(guildTransactions.userId, userId),
|
||||
eq(guildTransactions.type, 'usage'),
|
||||
gte(guildTransactions.createdAt, dayStart)
|
||||
)
|
||||
);
|
||||
|
||||
const spent = Number(dailySpent[0]?.total ?? 0);
|
||||
if (spent + amount > limit.dailyLimit) {
|
||||
throw new BadRequestError(
|
||||
`Daily spending limit exceeded (${limit.dailyLimit} credits/day)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (limit.monthlyLimit !== null) {
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
const monthlySpent = await this.db
|
||||
.select({ total: sql<number>`COALESCE(SUM(ABS(${guildTransactions.amount})), 0)` })
|
||||
.from(guildTransactions)
|
||||
.where(
|
||||
and(
|
||||
eq(guildTransactions.organizationId, guildId),
|
||||
eq(guildTransactions.userId, userId),
|
||||
eq(guildTransactions.type, 'usage'),
|
||||
gte(guildTransactions.createdAt, monthStart)
|
||||
)
|
||||
);
|
||||
|
||||
const spent = Number(monthlySpent[0]?.total ?? 0);
|
||||
if (spent + amount > limit.monthlyLimit) {
|
||||
throw new BadRequestError(
|
||||
`Monthly spending limit exceeded (${limit.monthlyLimit} credits/month)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue