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:
Till JS 2026-04-10 19:08:42 +02:00
parent 29ad31c4ed
commit e068335dd4
32 changed files with 143 additions and 922 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,2 @@
export * from './credits';
export * from './gifts';
export * from './guilds';

View file

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

View file

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

View file

@ -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) => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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