feat(services): create mana-credits service (Hono + Bun)

Extract the credit system from mana-core-auth into a standalone service.
Uses Hono framework on Bun runtime instead of NestJS.

Service includes:
- Personal credit balance with optimistic locking
- Immutable transaction ledger
- Stripe payment integration (PaymentIntents, Checkout Sessions)
- Guild shared pools with per-member spending limits
- Gift code system (simple, personalized, split, first_come, riddle)
- Service-to-service internal API (X-Service-Key auth)
- JWT validation via JWKS from mana-core-auth (jose library)

Architecture:
- 27 files, ~2.2k LOC (vs ~4.1k in NestJS)
- Drizzle ORM schemas adapted for standalone DB (no FK to auth tables)
- Zod validation instead of class-validator
- Manual service instantiation instead of NestJS DI
- Hono middleware for JWT + service key auth

Port: 3060
Database: mana_credits (separate from mana_auth)

Next steps: Update CreditClientService URL, update mana-core-auth
registration hooks, configure Docker + Cloudflare Tunnel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-27 22:08:43 +01:00
parent 02bd9d3117
commit 15deaf4e0a
27 changed files with 2373 additions and 0 deletions

View file

@ -0,0 +1,100 @@
# mana-credits
Standalone credit management service for the ManaCore ecosystem. Extracted from mana-core-auth.
## Tech Stack
| Layer | Technology |
|-------|------------|
| **Runtime** | Bun |
| **Framework** | Hono |
| **Database** | PostgreSQL + Drizzle ORM |
| **Payments** | Stripe (Payment Intents, Checkout Sessions) |
| **Auth** | JWT validation via JWKS from mana-core-auth |
## Quick Start
```bash
# Start (requires PostgreSQL running)
bun run dev
# Database
bun run db:push # Push schema
bun run db:studio # Open Drizzle Studio
```
## Port: 3060
## API Endpoints
### Personal Credits (JWT auth)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/credits/balance` | Get personal balance |
| POST | `/api/v1/credits/use` | Use credits (personal or guild) |
| 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 |
|--------|------|-------------|
| GET | `/api/v1/gifts/:code` | Get gift info (public) |
| POST | `/api/v1/gifts` | Create gift (JWT) |
| GET | `/api/v1/gifts/me/created` | My created gifts (JWT) |
| GET | `/api/v1/gifts/me/received` | My received gifts (JWT) |
| POST | `/api/v1/gifts/:code/redeem` | Redeem gift (JWT) |
| DELETE | `/api/v1/gifts/:id` | Cancel gift (JWT) |
### Internal (X-Service-Key auth)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/v1/internal/credits/balance/:userId` | Get user balance |
| POST | `/api/v1/internal/credits/use` | Use credits for user |
| 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
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/v1/webhooks/stripe` | Stripe payment events |
## Environment Variables
```env
PORT=3060
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/mana_credits
MANA_CORE_AUTH_URL=http://localhost:3001
MANA_CORE_SERVICE_KEY=dev-service-key
BASE_URL=http://localhost:3060
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
CORS_ORIGINS=http://localhost:5173,http://localhost:5180
```
## Database
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

View file

@ -0,0 +1,18 @@
FROM oven/bun:1 AS production
WORKDIR /app
# Copy package files and install
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile 2>/dev/null || bun install
# Copy source
COPY src ./src
COPY tsconfig.json drizzle.config.ts ./
EXPOSE 3060
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD bun -e "fetch('http://localhost:3060/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["bun", "run", "src/index.ts"]

View file

@ -0,0 +1,12 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema/*.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url:
process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/mana_credits',
},
schemaFilter: ['credits', 'gifts'],
});

View file

@ -0,0 +1,27 @@
{
"name": "@mana/credits",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"hono": "^4.7.0",
"drizzle-orm": "^0.38.3",
"postgres": "^3.4.5",
"stripe": "^17.5.0",
"jose": "^6.1.2",
"bcryptjs": "^3.0.2",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"drizzle-kit": "^0.30.4",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,44 @@
/**
* Application configuration loaded from environment variables.
*/
export interface Config {
port: number;
databaseUrl: string;
manaAuthUrl: string;
serviceKey: string;
baseUrl: string;
stripe: {
secretKey: string;
webhookSecret: string;
};
cors: {
origins: string[];
};
}
export function loadConfig(): Config {
const requiredEnv = (key: string, fallback?: string): string => {
const value = process.env[key] || fallback;
if (!value) throw new Error(`Missing required env var: ${key}`);
return value;
};
return {
port: parseInt(process.env.PORT || '3060', 10),
databaseUrl: requiredEnv(
'DATABASE_URL',
'postgresql://manacore:devpassword@localhost:5432/mana_credits'
),
manaAuthUrl: requiredEnv('MANA_CORE_AUTH_URL', 'http://localhost:3001'),
serviceKey: requiredEnv('MANA_CORE_SERVICE_KEY', 'dev-service-key'),
baseUrl: requiredEnv('BASE_URL', 'http://localhost:3060'),
stripe: {
secretKey: process.env.STRIPE_SECRET_KEY || '',
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
},
cors: {
origins: (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','),
},
};
}

View file

@ -0,0 +1,19 @@
/**
* Database connection using Drizzle ORM + postgres.js
*/
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema/index';
let db: ReturnType<typeof drizzle<typeof schema>> | null = null;
export function getDb(databaseUrl: string) {
if (!db) {
const client = postgres(databaseUrl, { max: 10 });
db = drizzle(client, { schema });
}
return db;
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,152 @@
/**
* Credits Schema Personal balance, transactions, packages, purchases
*
* Adapted from mana-core-auth: removed FK references to auth.users (separate DB).
* userId columns remain as text() without foreign key constraints.
*/
import {
pgSchema,
uuid,
integer,
text,
timestamp,
jsonb,
index,
pgEnum,
boolean,
} from 'drizzle-orm/pg-core';
export const creditsSchema = pgSchema('credits');
// ─── Enums ──────────────────────────────────────────────────
export const transactionTypeEnum = pgEnum('transaction_type', [
'purchase',
'usage',
'refund',
'gift',
'guild_funding',
]);
export const transactionStatusEnum = pgEnum('transaction_status', [
'pending',
'completed',
'failed',
'cancelled',
]);
// ─── Tables ─────────────────────────────────────────────────
/** Stripe customer mapping (reuse customers across purchases) */
export const stripeCustomers = creditsSchema.table('stripe_customers', {
userId: text('user_id').primaryKey(),
stripeCustomerId: text('stripe_customer_id').unique().notNull(),
email: text('email'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
/** Credit balances (one per user, optimistic locking via version) */
export const balances = creditsSchema.table('balances', {
userId: text('user_id').primaryKey(),
balance: integer('balance').default(0).notNull(),
totalEarned: integer('total_earned').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(),
});
/** Immutable transaction ledger */
export const transactions = creditsSchema.table(
'transactions',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
type: transactionTypeEnum('type').notNull(),
status: transactionStatusEnum('status').default('pending').notNull(),
amount: integer('amount').notNull(),
balanceBefore: integer('balance_before').notNull(),
balanceAfter: integer('balance_after').notNull(),
appId: text('app_id').notNull(),
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 }),
},
(table) => ({
userIdIdx: index('transactions_user_id_idx').on(table.userId),
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),
})
);
/** Credit packages (pricing tiers) */
export const packages = creditsSchema.table('packages', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
description: text('description'),
credits: integer('credits').notNull(),
priceEuroCents: integer('price_euro_cents').notNull(),
stripePriceId: text('stripe_price_id').unique(),
active: boolean('active').default(true).notNull(),
sortOrder: integer('sort_order').default(0).notNull(),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
/** Purchase history */
export const purchases = creditsSchema.table(
'purchases',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
packageId: uuid('package_id').references(() => packages.id),
credits: integer('credits').notNull(),
priceEuroCents: integer('price_euro_cents').notNull(),
stripePaymentIntentId: text('stripe_payment_intent_id').unique(),
stripeCustomerId: text('stripe_customer_id'),
status: transactionStatusEnum('status').default('pending').notNull(),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
},
(table) => ({
userIdIdx: index('purchases_user_id_idx').on(table.userId),
stripePaymentIntentIdIdx: index('purchases_stripe_payment_intent_id_idx').on(
table.stripePaymentIntentId
),
})
);
/** Usage tracking (analytics) */
export const usageStats = creditsSchema.table(
'usage_stats',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: text('user_id').notNull(),
appId: text('app_id').notNull(),
creditsUsed: integer('credits_used').notNull(),
date: timestamp('date', { withTimezone: true }).notNull(),
metadata: jsonb('metadata'),
},
(table) => ({
userIdDateIdx: index('usage_stats_user_id_date_idx').on(table.userId, table.date),
appIdDateIdx: index('usage_stats_app_id_date_idx').on(table.appId, table.date),
})
);
// ─── Type Exports ───────────────────────────────────────────
export type Balance = typeof balances.$inferSelect;
export type Transaction = typeof transactions.$inferSelect;
export type NewTransaction = typeof transactions.$inferInsert;
export type Package = typeof packages.$inferSelect;
export type Purchase = typeof purchases.$inferSelect;
export type NewPurchase = typeof purchases.$inferInsert;

View file

@ -0,0 +1,135 @@
/**
* Gifts Schema Gift codes, redemptions
*
* Adapted from mana-core-auth: removed FK references to auth.users.
* Added denormalized creatorName for display without cross-service calls.
*/
import { pgSchema, uuid, text, timestamp, integer, index, pgEnum } from 'drizzle-orm/pg-core';
export const giftsSchema = pgSchema('gifts');
// ─── Enums ──────────────────────────────────────────────────
export const giftCodeTypeEnum = pgEnum('gift_code_type', [
'simple',
'personalized',
'split',
'first_come',
'riddle',
]);
export const giftCodeStatusEnum = pgEnum('gift_code_status', [
'active',
'depleted',
'expired',
'cancelled',
'refunded',
]);
export const giftRedemptionStatusEnum = pgEnum('gift_redemption_status', [
'success',
'failed_wrong_answer',
'failed_wrong_user',
'failed_depleted',
'failed_expired',
'failed_already_claimed',
]);
// ─── Tables ─────────────────────────────────────────────────
/** Gift codes — user-generated codes for gifting credits */
export const giftCodes = giftsSchema.table(
'gift_codes',
{
id: uuid('id').primaryKey().defaultRandom(),
code: text('code').notNull().unique(),
shortUrl: text('short_url'),
creatorId: text('creator_id').notNull(),
creatorName: text('creator_name'), // Denormalized for display
// 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),
// Type and status
type: giftCodeTypeEnum('type').notNull().default('simple'),
status: giftCodeStatusEnum('status').notNull().default('active'),
// Personalization
targetEmail: text('target_email'),
targetMatrixId: text('target_matrix_id'),
// Riddle
riddleQuestion: text('riddle_question'),
riddleAnswerHash: text('riddle_answer_hash'),
// Message
message: text('message'),
// Expiration
expiresAt: timestamp('expires_at', { withTimezone: true }),
// Reference to credit reservation transaction
reservationTransactionId: uuid('reservation_transaction_id'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
codeLookupIdx: index('gift_codes_code_idx').on(table.code),
creatorIdx: index('gift_codes_creator_idx').on(table.creatorId),
statusIdx: index('gift_codes_status_idx').on(table.status),
expiresAtIdx: index('gift_codes_expires_at_idx').on(table.expiresAt),
})
);
/** Gift redemptions — tracks each redemption attempt */
export const giftRedemptions = giftsSchema.table(
'gift_redemptions',
{
id: uuid('id').primaryKey().defaultRandom(),
giftCodeId: uuid('gift_code_id')
.notNull()
.references(() => giftCodes.id, { onDelete: 'cascade' }),
redeemerUserId: text('redeemer_user_id').notNull(),
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'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
giftCodeIdx: index('gift_redemptions_gift_code_idx').on(table.giftCodeId),
redeemerIdx: index('gift_redemptions_redeemer_idx').on(table.redeemerUserId),
statusIdx: index('gift_redemptions_status_idx').on(table.status),
})
);
// ─── Type Exports ───────────────────────────────────────────
export type GiftCode = typeof giftCodes.$inferSelect;
export type NewGiftCode = typeof giftCodes.$inferInsert;
export type GiftRedemption = typeof giftRedemptions.$inferSelect;
export type NewGiftRedemption = typeof giftRedemptions.$inferInsert;
// ─── Constants ──────────────────────────────────────────────
export const GIFT_CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
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

@ -0,0 +1,79 @@
/**
* Guild Pool Schema Shared Mana pools for organizations
*
* Adapted from mana-core-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

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

View file

@ -0,0 +1,82 @@
/**
* mana-credits Standalone credit management service
*
* Hono + Bun runtime. Extracted from mana-core-auth.
* Handles: personal credits, guild pools, gift codes, Stripe payments.
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { loadConfig } from './config';
import { getDb } from './db/connection';
import { errorHandler } from './middleware/error-handler';
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';
// ─── Bootstrap ──────────────────────────────────────────────
const config = loadConfig();
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 giftCodeService = new GiftCodeService(db, config.baseUrl);
// ─── App ────────────────────────────────────────────────────
const app = new Hono();
// Global middleware
app.onError(errorHandler);
app.use(
'*',
cors({
origin: config.cors.origins,
credentials: true,
})
);
// Health check (no auth)
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)
);
// Stripe webhooks (verified by signature, no auth middleware)
app.route(
'/api/v1/webhooks',
createWebhookRoutes(stripeService, creditsService, config.stripe.webhookSecret)
);
// ─── Start ──────────────────────────────────────────────────
console.log(`mana-credits starting on port ${config.port}...`);
export default {
port: config.port,
fetch: app.fetch,
};

View file

@ -0,0 +1,43 @@
import { HTTPException } from 'hono/http-exception';
export class BadRequestError extends HTTPException {
constructor(message: string) {
super(400, { message });
}
}
export class UnauthorizedError extends HTTPException {
constructor(message = 'Unauthorized') {
super(401, { message });
}
}
export class ForbiddenError extends HTTPException {
constructor(message = 'Forbidden') {
super(403, { message });
}
}
export class NotFoundError extends HTTPException {
constructor(message = 'Not found') {
super(404, { message });
}
}
export class ConflictError extends HTTPException {
constructor(message = 'Conflict') {
super(409, { message });
}
}
export class InsufficientCreditsError extends HTTPException {
constructor(
public readonly required: number,
public readonly available: number
) {
super(402, {
message: 'Insufficient credits',
cause: { required, available },
});
}
}

View file

@ -0,0 +1,95 @@
/**
* Zod schemas for request body validation.
*/
import { z } from 'zod';
// ─── Credits ────────────────────────────────────────────────
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(),
});
export const purchaseCreditsSchema = z.object({
packageId: z.string().uuid(),
});
export const createPaymentLinkSchema = z.object({
packageId: z.string().uuid(),
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),
targetEmail: z.string().email().optional(),
targetMatrixId: z.string().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(),
});
// ─── Internal (Service-to-Service) ──────────────────────────
export const internalUseCreditsSchema = z.object({
userId: z.string().min(1),
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(),
});
export const internalRefundSchema = z.object({
userId: z.string().min(1),
amount: z.number().positive(),
description: z.string().min(1),
appId: z.string().default('system'),
metadata: z.record(z.unknown()).optional(),
});
export const internalInitSchema = z.object({
userId: z.string().min(1),
});
export const internalRedeemPendingSchema = z.object({
userId: z.string().min(1),
email: z.string().email(),
});

View file

@ -0,0 +1,29 @@
/**
* Global error handler middleware for Hono.
*/
import type { ErrorHandler } from 'hono';
import { HTTPException } from 'hono/http-exception';
export const errorHandler: ErrorHandler = (err, c) => {
if (err instanceof HTTPException) {
const cause = err.cause as Record<string, unknown> | undefined;
return c.json(
{
statusCode: err.status,
message: err.message,
...(cause ? { details: cause } : {}),
},
err.status
);
}
console.error('Unhandled error:', err);
return c.json(
{
statusCode: 500,
message: 'Internal server error',
},
500
);
};

View file

@ -0,0 +1,57 @@
/**
* JWT Authentication Middleware
*
* Validates Bearer tokens via JWKS from mana-core-auth.
* Uses jose library with EdDSA algorithm.
*/
import type { MiddlewareHandler } from 'hono';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { UnauthorizedError } from '../lib/errors';
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
function getJwks(authUrl: string) {
if (!jwks) {
jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl));
}
return jwks;
}
export interface AuthUser {
userId: string;
email: string;
role: string;
}
/**
* Middleware that validates JWT tokens from Authorization: Bearer header.
* Sets c.set('user', { userId, email, role }) on success.
*/
export function jwtAuth(authUrl: string): MiddlewareHandler {
return async (c, next) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedError('Missing or invalid Authorization header');
}
const token = authHeader.slice(7);
try {
const { payload } = await jwtVerify(token, getJwks(authUrl), {
issuer: authUrl,
audience: 'manacore',
});
const user: AuthUser = {
userId: payload.sub || '',
email: (payload.email as string) || '',
role: (payload.role as string) || 'user',
};
c.set('user', user);
await next();
} catch {
throw new UnauthorizedError('Invalid or expired token');
}
};
}

View file

@ -0,0 +1,26 @@
/**
* Service-to-Service Authentication Middleware
*
* Validates X-Service-Key header for backend-to-backend calls.
* Used by /internal/* routes.
*/
import type { MiddlewareHandler } from 'hono';
import { UnauthorizedError } from '../lib/errors';
/**
* Middleware that validates X-Service-Key header.
* Sets c.set('appId', ...) from X-App-Id header.
*/
export function serviceAuth(serviceKey: string): MiddlewareHandler {
return async (c, next) => {
const key = c.req.header('X-Service-Key');
if (!key || key !== serviceKey) {
throw new UnauthorizedError('Invalid or missing service key');
}
const appId = c.req.header('X-App-Id') || 'unknown';
c.set('appId', appId);
await next();
};
}

View file

@ -0,0 +1,53 @@
/**
* Credit routes personal balance endpoints (JWT auth)
*/
import { Hono } from 'hono';
import type { CreditsService } from '../services/credits';
import type { AuthUser } from '../middleware/jwt-auth';
import { useCreditsSchema, purchaseCreditsSchema } from '../lib/validation';
export function createCreditsRoutes(creditsService: CreditsService) {
return new Hono<{ Variables: { user: AuthUser } }>()
.get('/balance', async (c) => {
const user = c.get('user');
const balance = await creditsService.getBalance(user.userId);
return c.json(balance);
})
.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);
return c.json(result);
})
.get('/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 creditsService.getTransactions(user.userId, limit, offset);
return c.json(txs);
})
.get('/purchases', async (c) => {
const user = c.get('user');
const purchases = await creditsService.getPurchases(user.userId);
return c.json(purchases);
})
.get('/packages', async (c) => {
const pkgs = await creditsService.getPackages();
return c.json(pkgs);
})
.post('/purchase', async (c) => {
const user = c.get('user');
const body = purchaseCreditsSchema.parse(await c.req.json());
const result = await creditsService.initiatePurchase(user.userId, body.packageId, user.email);
return c.json(result);
})
.get('/purchase/:purchaseId', async (c) => {
const user = c.get('user');
const purchase = await creditsService.getPurchaseStatus(
user.userId,
c.req.param('purchaseId')
);
return c.json(purchase);
});
}

View file

@ -0,0 +1,63 @@
/**
* Gift code routes mixed auth (public GET /:code, JWT for rest)
*/
import { Hono } from 'hono';
import type { GiftCodeService } from '../services/gift-code';
import type { AuthUser } from '../middleware/jwt-auth';
import { jwtAuth } from '../middleware/jwt-auth';
import { createGiftSchema, redeemGiftSchema } from '../lib/validation';
export function createGiftRoutes(giftCodeService: GiftCodeService, authUrl: string) {
const app = new Hono();
// Public: Get gift info by code
app.get('/:code', async (c) => {
const info = await giftCodeService.getGiftInfo(c.req.param('code'));
return c.json(info);
});
// Protected routes
const authed = new Hono<{ Variables: { user: AuthUser } }>();
authed.use('*', jwtAuth(authUrl));
authed.post('/', async (c) => {
const user = c.get('user');
const body = createGiftSchema.parse(await c.req.json());
const gift = await giftCodeService.createGift(user.userId, user.email, body);
return c.json(gift, 201);
});
authed.get('/me/created', async (c) => {
const user = c.get('user');
const gifts = await giftCodeService.getCreatedGifts(user.userId);
return c.json(gifts);
});
authed.get('/me/received', async (c) => {
const user = c.get('user');
const gifts = await giftCodeService.getReceivedGifts(user.userId);
return c.json(gifts);
});
authed.post('/:code/redeem', async (c) => {
const user = c.get('user');
const body = redeemGiftSchema.parse(await c.req.json());
const result = await giftCodeService.redeemGift(
c.req.param('code'),
user.userId,
body.riddleAnswer,
body.sourceAppId
);
return c.json(result);
});
authed.delete('/:id', async (c) => {
const user = c.get('user');
const result = await giftCodeService.cancelGift(c.req.param('id'), user.userId);
return c.json(result);
});
app.route('/', authed);
return app;
}

View file

@ -0,0 +1,69 @@
/**
* 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

@ -0,0 +1,5 @@
import { Hono } from 'hono';
export const healthRoutes = new Hono().get('/', (c) =>
c.json({ status: 'ok', service: 'mana-credits', timestamp: new Date().toISOString() })
);

View file

@ -0,0 +1,58 @@
/**
* Internal routes service-to-service endpoints (X-Service-Key auth)
*/
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,
internalInitSchema,
internalRedeemPendingSchema,
} from '../lib/validation';
export function createInternalRoutes(
creditsService: CreditsService,
giftCodeService: GiftCodeService,
guildPoolService: GuildPoolService
) {
return new Hono()
.get('/credits/balance/:userId', async (c) => {
const balance = await creditsService.getBalance(c.req.param('userId'));
return c.json(balance);
})
.post('/credits/use', async (c) => {
const body = internalUseCreditsSchema.parse(await c.req.json());
const { userId, ...params } = body;
const result = await creditsService.useCreditsWithSource(userId, params);
return c.json(result);
})
.post('/credits/refund', async (c) => {
const body = internalRefundSchema.parse(await c.req.json());
const result = await creditsService.refundCredits(
body.userId,
body.amount,
body.description,
body.appId,
body.metadata
);
return c.json(result);
})
.post('/credits/init', async (c) => {
const body = internalInitSchema.parse(await c.req.json());
const balance = await creditsService.initializeBalance(body.userId);
return c.json(balance);
})
.post('/gifts/redeem-pending', async (c) => {
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

@ -0,0 +1,54 @@
/**
* Stripe webhook handler credit-related events only
*/
import { Hono } from 'hono';
import type { StripeService } from '../services/stripe';
import type { CreditsService } from '../services/credits';
export function createWebhookRoutes(
stripeService: StripeService,
creditsService: CreditsService,
webhookSecret: string
) {
return new Hono().post('/stripe', async (c) => {
const signature = c.req.header('stripe-signature');
if (!signature) return c.json({ error: 'Missing signature' }, 400);
const rawBody = await c.req.text();
let event;
try {
event = stripeService.verifyWebhookSignature(rawBody, signature, webhookSecret);
} catch {
return c.json({ error: 'Invalid signature' }, 400);
}
switch (event.type) {
case 'payment_intent.succeeded': {
const pi = event.data.object;
await creditsService.completePurchase(pi.id);
break;
}
case 'payment_intent.payment_failed': {
const pi = event.data.object;
await creditsService.failPurchase(pi.id);
break;
}
case 'checkout.session.completed': {
// Checkout sessions create PaymentIntents which trigger payment_intent.succeeded
// No additional action needed here unless using direct checkout mode
break;
}
case 'payment_intent.processing': {
// SEPA debit processing — no action needed, wait for success/failure
console.log('Payment processing (SEPA):', event.data.object.id);
break;
}
default:
console.log('Unhandled webhook event:', event.type);
}
return c.json({ received: true });
});
}

View file

@ -0,0 +1,361 @@
/**
* Credits Service Personal balance management
*
* Ported from mana-core-auth CreditsService.
* Handles balance CRUD, credit usage, purchases, and transaction ledger.
*/
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,
ConflictError,
InsufficientCreditsError,
} from '../lib/errors';
interface UseCreditsParams {
amount: number;
appId: string;
description: string;
creditSource?: { type: 'guild'; guildId: string };
idempotencyKey?: string;
metadata?: Record<string, unknown>;
}
export class CreditsService {
constructor(
private db: Database,
private stripeService: StripeService,
private guildPoolService: GuildPoolService
) {}
async initializeBalance(userId: string) {
const [existing] = await this.db
.select()
.from(balances)
.where(eq(balances.userId, userId))
.limit(1);
if (existing) return existing;
const [balance] = await this.db
.insert(balances)
.values({ userId, balance: 0, totalEarned: 0, totalSpent: 0 })
.returning();
return balance;
}
async getBalance(userId: string) {
const [balance] = await this.db
.select()
.from(balances)
.where(eq(balances.userId, userId))
.limit(1);
if (!balance) return this.initializeBalance(userId);
return {
balance: balance.balance,
totalEarned: balance.totalEarned,
totalSpent: balance.totalSpent,
};
}
async useCredits(userId: string, params: UseCreditsParams) {
// Idempotency check
if (params.idempotencyKey) {
const [existing] = await this.db
.select()
.from(transactions)
.where(eq(transactions.idempotencyKey, params.idempotencyKey))
.limit(1);
if (existing) {
return { success: true, transaction: existing, message: 'Transaction already processed' };
}
}
return await this.db.transaction(async (tx) => {
// Row lock
const [current] = await tx
.select()
.from(balances)
.where(eq(balances.userId, userId))
.for('update')
.limit(1);
if (!current) throw new NotFoundError('User balance not found');
if (current.balance < params.amount) {
throw new InsufficientCreditsError(params.amount, current.balance);
}
const newBalance = current.balance - params.amount;
const newTotalSpent = current.totalSpent + params.amount;
// Optimistic locking update
const updateResult = await tx
.update(balances)
.set({
balance: newBalance,
totalSpent: newTotalSpent,
version: current.version + 1,
updatedAt: new Date(),
})
.where(and(eq(balances.userId, userId), eq(balances.version, current.version)))
.returning();
if (updateResult.length === 0) {
throw new ConflictError('Balance was modified concurrently. Please retry.');
}
// Ledger entry
const [transaction] = await tx
.insert(transactions)
.values({
userId,
type: 'usage',
status: 'completed',
amount: -params.amount,
balanceBefore: current.balance,
balanceAfter: newBalance,
appId: params.appId,
description: params.description,
metadata: params.metadata,
idempotencyKey: params.idempotencyKey,
completedAt: new Date(),
})
.returning();
// Usage stats
const today = new Date();
today.setHours(0, 0, 0, 0);
await tx.insert(usageStats).values({
userId,
appId: params.appId,
creditsUsed: params.amount,
date: today,
metadata: params.metadata,
});
return {
success: true,
transaction,
newBalance: { balance: newBalance, totalSpent: newTotalSpent },
};
});
}
/** 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,
description: string,
appId = 'system',
metadata?: Record<string, unknown>
) {
return await this.db.transaction(async (tx) => {
const [current] = await tx
.select()
.from(balances)
.where(eq(balances.userId, userId))
.for('update')
.limit(1);
if (!current) throw new NotFoundError('User balance not found');
const newBalance = current.balance + amount;
await tx
.update(balances)
.set({
balance: newBalance,
totalEarned: current.totalEarned + amount,
version: current.version + 1,
updatedAt: new Date(),
})
.where(and(eq(balances.userId, userId), eq(balances.version, current.version)))
.returning();
const [transaction] = await tx
.insert(transactions)
.values({
userId,
type: 'refund',
status: 'completed',
amount,
balanceBefore: current.balance,
balanceAfter: newBalance,
appId,
description,
metadata,
completedAt: new Date(),
})
.returning();
return { success: true, transaction, newBalance: { balance: newBalance } };
});
}
async getTransactions(userId: string, limit = 50, offset = 0) {
return this.db
.select()
.from(transactions)
.where(eq(transactions.userId, userId))
.orderBy(desc(transactions.createdAt))
.limit(limit)
.offset(offset);
}
async getPurchases(userId: string) {
return this.db
.select()
.from(purchases)
.where(eq(purchases.userId, userId))
.orderBy(desc(purchases.createdAt));
}
async getPackages() {
return this.db
.select()
.from(packages)
.where(eq(packages.active, true))
.orderBy(packages.sortOrder);
}
async initiatePurchase(userId: string, packageId: string, userEmail: string) {
const [pkg] = await this.db
.select()
.from(packages)
.where(and(eq(packages.id, packageId), eq(packages.active, true)))
.limit(1);
if (!pkg) throw new NotFoundError('Package not found or inactive');
const stripeCustomerId = await this.stripeService.getOrCreateCustomer(userId, userEmail);
const [purchase] = await this.db
.insert(purchases)
.values({
userId,
packageId,
credits: pkg.credits,
priceEuroCents: pkg.priceEuroCents,
stripeCustomerId,
status: 'pending',
})
.returning();
const paymentIntent = await this.stripeService.createPaymentIntent(
stripeCustomerId,
pkg.priceEuroCents,
{ userId, packageId, purchaseId: purchase.id }
);
await this.db
.update(purchases)
.set({ stripePaymentIntentId: paymentIntent.id })
.where(eq(purchases.id, purchase.id));
return {
purchaseId: purchase.id,
clientSecret: paymentIntent.client_secret!,
amount: pkg.priceEuroCents,
credits: pkg.credits,
};
}
async completePurchase(paymentIntentId: string) {
return await this.db.transaction(async (tx) => {
const [purchase] = await tx
.select()
.from(purchases)
.where(eq(purchases.stripePaymentIntentId, paymentIntentId))
.limit(1);
if (!purchase) throw new NotFoundError('Purchase not found');
if (purchase.status === 'completed') return { success: true, already: true };
// Lock and update balance
const [current] = await tx
.select()
.from(balances)
.where(eq(balances.userId, purchase.userId))
.for('update')
.limit(1);
const balanceBefore = current?.balance ?? 0;
const newBalance = balanceBefore + purchase.credits;
if (current) {
await tx
.update(balances)
.set({
balance: newBalance,
totalEarned: current.totalEarned + purchase.credits,
version: current.version + 1,
updatedAt: new Date(),
})
.where(eq(balances.userId, purchase.userId));
} else {
await tx.insert(balances).values({
userId: purchase.userId,
balance: purchase.credits,
totalEarned: purchase.credits,
totalSpent: 0,
});
}
// Ledger entry
await tx.insert(transactions).values({
userId: purchase.userId,
type: 'purchase',
status: 'completed',
amount: purchase.credits,
balanceBefore,
balanceAfter: newBalance,
appId: 'stripe',
description: `Credit purchase: ${purchase.credits} credits`,
metadata: { purchaseId: purchase.id, paymentIntentId },
completedAt: new Date(),
});
// Mark purchase complete
await tx
.update(purchases)
.set({ status: 'completed', completedAt: new Date() })
.where(eq(purchases.id, purchase.id));
return { success: true };
});
}
async failPurchase(paymentIntentId: string) {
await this.db
.update(purchases)
.set({ status: 'failed' })
.where(eq(purchases.stripePaymentIntentId, paymentIntentId));
}
async getPurchaseStatus(userId: string, purchaseId: string) {
const [purchase] = await this.db
.select()
.from(purchases)
.where(and(eq(purchases.id, purchaseId), eq(purchases.userId, userId)))
.limit(1);
if (!purchase) throw new NotFoundError('Purchase not found');
return purchase;
}
}

View file

@ -0,0 +1,367 @@
/**
* Gift Code Service Gift code generation, redemption, cancellation
*
* Ported from mana-core-auth GiftCodeService.
*/
import { eq, and, desc } from 'drizzle-orm';
import bcrypt from 'bcryptjs';
import {
giftCodes,
giftRedemptions,
GIFT_CODE_CHARS,
GIFT_CODE_LENGTH,
GIFT_CODE_RULES,
} from '../db/schema/gifts';
import { balances, transactions } from '../db/schema/credits';
import type { Database } from '../db/connection';
import { BadRequestError, NotFoundError } from '../lib/errors';
interface CreateGiftParams {
totalCredits: number;
type?: 'simple' | 'personalized' | 'split' | 'first_come' | 'riddle';
totalPortions?: number;
targetEmail?: string;
targetMatrixId?: string;
riddleQuestion?: string;
riddleAnswer?: string;
message?: string;
expirationDays?: number;
}
export class GiftCodeService {
constructor(
private db: Database,
private baseUrl: string
) {}
private generateCode(): string {
let code = '';
for (let i = 0; i < GIFT_CODE_LENGTH; i++) {
code += GIFT_CODE_CHARS[Math.floor(Math.random() * GIFT_CODE_CHARS.length)];
}
return code;
}
async createGift(creatorId: string, creatorName: string, params: CreateGiftParams) {
const { totalCredits, type = 'simple', totalPortions = 1 } = params;
if (totalCredits < GIFT_CODE_RULES.minCredits || totalCredits > GIFT_CODE_RULES.maxCredits) {
throw new BadRequestError(
`Credits must be between ${GIFT_CODE_RULES.minCredits} and ${GIFT_CODE_RULES.maxCredits}`
);
}
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()
.from(balances)
.where(eq(balances.userId, creatorId))
.for('update')
.limit(1);
if (!balance || balance.balance < totalCredits) {
throw new BadRequestError('Insufficient credits to create gift');
}
// Deduct from creator
await tx
.update(balances)
.set({
balance: balance.balance - totalCredits,
totalSpent: balance.totalSpent + totalCredits,
version: balance.version + 1,
updatedAt: new Date(),
})
.where(and(eq(balances.userId, creatorId), eq(balances.version, balance.version)));
// Reservation transaction
const [reservationTx] = await tx
.insert(transactions)
.values({
userId: creatorId,
type: 'gift',
status: 'completed',
amount: -totalCredits,
balanceBefore: balance.balance,
balanceAfter: balance.balance - totalCredits,
appId: 'gifts',
description: `Gift code created: ${totalCredits} credits`,
completedAt: new Date(),
})
.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)
: new Date(Date.now() + GIFT_CODE_RULES.defaultExpirationDays * 24 * 60 * 60 * 1000);
const [gift] = await tx
.insert(giftCodes)
.values({
code,
shortUrl: `${this.baseUrl}/g/${code}`,
creatorId,
creatorName,
totalCredits,
creditsPerPortion,
totalPortions,
type,
targetEmail: params.targetEmail,
targetMatrixId: params.targetMatrixId,
riddleQuestion: params.riddleQuestion,
riddleAnswerHash,
message: params.message,
expiresAt,
reservationTransactionId: reservationTx.id,
})
.returning();
return gift;
});
}
async redeemGift(code: string, redeemerId: string, riddleAnswer?: string, sourceAppId?: string) {
return await this.db.transaction(async (tx) => {
const [gift] = await tx
.select()
.from(giftCodes)
.where(eq(giftCodes.code, code.toUpperCase()))
.for('update')
.limit(1);
if (!gift) throw new NotFoundError('Gift code not found');
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');
}
}
// Add credits to redeemer
const [balance] = await tx
.select()
.from(balances)
.where(eq(balances.userId, redeemerId))
.for('update')
.limit(1);
const balanceBefore = balance?.balance ?? 0;
const newBalance = balanceBefore + gift.creditsPerPortion;
if (balance) {
await tx
.update(balances)
.set({
balance: newBalance,
totalEarned: balance.totalEarned + gift.creditsPerPortion,
version: balance.version + 1,
updatedAt: new Date(),
})
.where(eq(balances.userId, redeemerId));
} else {
await tx.insert(balances).values({
userId: redeemerId,
balance: gift.creditsPerPortion,
totalEarned: gift.creditsPerPortion,
totalSpent: 0,
});
}
// Ledger
const [creditTx] = await tx
.insert(transactions)
.values({
userId: redeemerId,
type: 'gift',
status: 'completed',
amount: gift.creditsPerPortion,
balanceBefore,
balanceAfter: newBalance,
appId: 'gifts',
description: `Gift redeemed: ${gift.creditsPerPortion} credits from ${gift.creatorName || 'someone'}`,
completedAt: new Date(),
})
.returning();
// Track redemption
await tx.insert(giftRedemptions).values({
giftCodeId: gift.id,
redeemerUserId: redeemerId,
status: 'success',
creditsReceived: gift.creditsPerPortion,
portionNumber: gift.claimedPortions + 1,
creditTransactionId: creditTx.id,
sourceAppId,
});
// Update gift
const newClaimedPortions = gift.claimedPortions + 1;
const newStatus = newClaimedPortions >= gift.totalPortions ? 'depleted' : 'active';
await tx
.update(giftCodes)
.set({ claimedPortions: newClaimedPortions, status: newStatus, updatedAt: new Date() })
.where(eq(giftCodes.id, gift.id));
return {
success: true,
creditsReceived: gift.creditsPerPortion,
message: gift.message,
creatorName: gift.creatorName,
};
});
}
async getGiftInfo(code: string) {
const [gift] = await this.db
.select()
.from(giftCodes)
.where(eq(giftCodes.code, code.toUpperCase()))
.limit(1);
if (!gift) throw new NotFoundError('Gift code not found');
return {
code: gift.code,
type: gift.type,
status: gift.status,
creditsPerPortion: gift.creditsPerPortion,
totalPortions: gift.totalPortions,
claimedPortions: gift.claimedPortions,
riddleQuestion: gift.riddleQuestion,
message: gift.message,
creatorName: gift.creatorName,
expiresAt: gift.expiresAt,
};
}
async getCreatedGifts(userId: string) {
return this.db
.select()
.from(giftCodes)
.where(eq(giftCodes.creatorId, userId))
.orderBy(desc(giftCodes.createdAt));
}
async getReceivedGifts(userId: string) {
return this.db
.select()
.from(giftRedemptions)
.where(eq(giftRedemptions.redeemerUserId, userId))
.orderBy(desc(giftRedemptions.createdAt));
}
async cancelGift(giftId: string, userId: string) {
return await this.db.transaction(async (tx) => {
const [gift] = await tx
.select()
.from(giftCodes)
.where(and(eq(giftCodes.id, giftId), eq(giftCodes.creatorId, userId)))
.for('update')
.limit(1);
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;
if (refundAmount > 0) {
const [balance] = await tx
.select()
.from(balances)
.where(eq(balances.userId, userId))
.for('update')
.limit(1);
if (balance) {
await tx
.update(balances)
.set({
balance: balance.balance + refundAmount,
totalEarned: balance.totalEarned + refundAmount,
version: balance.version + 1,
updatedAt: new Date(),
})
.where(eq(balances.userId, userId));
await tx.insert(transactions).values({
userId,
type: 'refund',
status: 'completed',
amount: refundAmount,
balanceBefore: balance.balance,
balanceAfter: balance.balance + refundAmount,
appId: 'gifts',
description: `Gift cancelled, ${refundAmount} credits refunded`,
completedAt: new Date(),
});
}
}
await tx
.update(giftCodes)
.set({ status: 'cancelled', updatedAt: new Date() })
.where(eq(giftCodes.id, giftId));
return { success: true, refundedCredits: refundAmount };
});
}
/** Auto-redeem pending gifts for a newly registered user */
async redeemPendingForUser(userId: string, email: string) {
const pendingGifts = await this.db
.select()
.from(giftCodes)
.where(
and(
eq(giftCodes.targetEmail, email),
eq(giftCodes.status, 'active'),
eq(giftCodes.type, 'personalized')
)
);
let totalRedeemed = 0;
for (const gift of pendingGifts) {
try {
const result = await this.redeemGift(gift.code, userId, undefined, 'auto-registration');
totalRedeemed += result.creditsReceived;
} catch {
// Skip failed redemptions
}
}
return { redeemed: pendingGifts.length, totalCredits: totalRedeemed };
}
}

View file

@ -0,0 +1,316 @@
/**
* Guild Pool Service Shared organization credit pools
*
* Ported from mana-core-auth GuildPoolService.
* Membership checks via HTTP call to mana-core-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-core-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)`
);
}
}
}
}

View file

@ -0,0 +1,89 @@
/**
* Stripe Service Payment integration
*
* Handles customer management, payment intents, checkout sessions,
* and webhook signature verification.
*/
import Stripe from 'stripe';
import { eq } from 'drizzle-orm';
import { stripeCustomers } from '../db/schema/credits';
import type { Database } from '../db/connection';
export class StripeService {
private stripe: Stripe | null;
constructor(
private db: Database,
secretKey: string
) {
this.stripe = secretKey ? new Stripe(secretKey, { apiVersion: '2025-04-30.basil' }) : null;
}
private getStripe(): Stripe {
if (!this.stripe) throw new Error('Stripe not configured (missing STRIPE_SECRET_KEY)');
return this.stripe;
}
async getOrCreateCustomer(userId: string, email: string): Promise<string> {
// Check existing mapping
const [existing] = await this.db
.select()
.from(stripeCustomers)
.where(eq(stripeCustomers.userId, userId))
.limit(1);
if (existing) return existing.stripeCustomerId;
// Create new Stripe customer
const customer = await this.getStripe().customers.create({
email,
metadata: { userId },
});
await this.db.insert(stripeCustomers).values({
userId,
stripeCustomerId: customer.id,
email,
});
return customer.id;
}
async createPaymentIntent(
customerId: string,
amountCents: number,
metadata: Record<string, string>
) {
return this.getStripe().paymentIntents.create({
amount: amountCents,
currency: 'eur',
customer: customerId,
payment_method_types: ['card', 'sepa_debit'],
metadata,
});
}
async createCheckoutSession(params: {
customerId: string;
priceId: string;
quantity: number;
successUrl: string;
cancelUrl: string;
metadata: Record<string, string>;
}) {
return this.getStripe().checkout.sessions.create({
customer: params.customerId,
mode: 'payment',
line_items: [{ price: params.priceId, quantity: params.quantity }],
success_url: params.successUrl,
cancel_url: params.cancelUrl,
metadata: params.metadata,
expires_after: 86400, // 24 hours
});
}
verifyWebhookSignature(body: string | Buffer, signature: string, secret: string): Stripe.Event {
return this.getStripe().webhooks.constructEvent(body, signature, secret);
}
}

View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts"]
}