diff --git a/services/mana-credits/CLAUDE.md b/services/mana-credits/CLAUDE.md new file mode 100644 index 000000000..f323bbdb5 --- /dev/null +++ b/services/mana-credits/CLAUDE.md @@ -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 diff --git a/services/mana-credits/Dockerfile b/services/mana-credits/Dockerfile new file mode 100644 index 000000000..a15514e96 --- /dev/null +++ b/services/mana-credits/Dockerfile @@ -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"] diff --git a/services/mana-credits/drizzle.config.ts b/services/mana-credits/drizzle.config.ts new file mode 100644 index 000000000..ebfe36f4f --- /dev/null +++ b/services/mana-credits/drizzle.config.ts @@ -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'], +}); diff --git a/services/mana-credits/package.json b/services/mana-credits/package.json new file mode 100644 index 000000000..2a689a148 --- /dev/null +++ b/services/mana-credits/package.json @@ -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" + } +} diff --git a/services/mana-credits/src/config.ts b/services/mana-credits/src/config.ts new file mode 100644 index 000000000..5603fafe8 --- /dev/null +++ b/services/mana-credits/src/config.ts @@ -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(','), + }, + }; +} diff --git a/services/mana-credits/src/db/connection.ts b/services/mana-credits/src/db/connection.ts new file mode 100644 index 000000000..aa63e328e --- /dev/null +++ b/services/mana-credits/src/db/connection.ts @@ -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> | 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; diff --git a/services/mana-credits/src/db/schema/credits.ts b/services/mana-credits/src/db/schema/credits.ts new file mode 100644 index 000000000..d818b1f4d --- /dev/null +++ b/services/mana-credits/src/db/schema/credits.ts @@ -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; diff --git a/services/mana-credits/src/db/schema/gifts.ts b/services/mana-credits/src/db/schema/gifts.ts new file mode 100644 index 000000000..8d1a3b15a --- /dev/null +++ b/services/mana-credits/src/db/schema/gifts.ts @@ -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; diff --git a/services/mana-credits/src/db/schema/guilds.ts b/services/mana-credits/src/db/schema/guilds.ts new file mode 100644 index 000000000..12508a357 --- /dev/null +++ b/services/mana-credits/src/db/schema/guilds.ts @@ -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; diff --git a/services/mana-credits/src/db/schema/index.ts b/services/mana-credits/src/db/schema/index.ts new file mode 100644 index 000000000..f6615c5fe --- /dev/null +++ b/services/mana-credits/src/db/schema/index.ts @@ -0,0 +1,3 @@ +export * from './credits'; +export * from './gifts'; +export * from './guilds'; diff --git a/services/mana-credits/src/index.ts b/services/mana-credits/src/index.ts new file mode 100644 index 000000000..24c8d6739 --- /dev/null +++ b/services/mana-credits/src/index.ts @@ -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, +}; diff --git a/services/mana-credits/src/lib/errors.ts b/services/mana-credits/src/lib/errors.ts new file mode 100644 index 000000000..d3b2c3392 --- /dev/null +++ b/services/mana-credits/src/lib/errors.ts @@ -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 }, + }); + } +} diff --git a/services/mana-credits/src/lib/validation.ts b/services/mana-credits/src/lib/validation.ts new file mode 100644 index 000000000..20b3ffef8 --- /dev/null +++ b/services/mana-credits/src/lib/validation.ts @@ -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(), +}); diff --git a/services/mana-credits/src/middleware/error-handler.ts b/services/mana-credits/src/middleware/error-handler.ts new file mode 100644 index 000000000..cec6640e8 --- /dev/null +++ b/services/mana-credits/src/middleware/error-handler.ts @@ -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 | 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 + ); +}; diff --git a/services/mana-credits/src/middleware/jwt-auth.ts b/services/mana-credits/src/middleware/jwt-auth.ts new file mode 100644 index 000000000..390319288 --- /dev/null +++ b/services/mana-credits/src/middleware/jwt-auth.ts @@ -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 | 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'); + } + }; +} diff --git a/services/mana-credits/src/middleware/service-auth.ts b/services/mana-credits/src/middleware/service-auth.ts new file mode 100644 index 000000000..a1012a11d --- /dev/null +++ b/services/mana-credits/src/middleware/service-auth.ts @@ -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(); + }; +} diff --git a/services/mana-credits/src/routes/credits.ts b/services/mana-credits/src/routes/credits.ts new file mode 100644 index 000000000..2e92b15ce --- /dev/null +++ b/services/mana-credits/src/routes/credits.ts @@ -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); + }); +} diff --git a/services/mana-credits/src/routes/gifts.ts b/services/mana-credits/src/routes/gifts.ts new file mode 100644 index 000000000..8340f2259 --- /dev/null +++ b/services/mana-credits/src/routes/gifts.ts @@ -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; +} diff --git a/services/mana-credits/src/routes/guild.ts b/services/mana-credits/src/routes/guild.ts new file mode 100644 index 000000000..79653dd7d --- /dev/null +++ b/services/mana-credits/src/routes/guild.ts @@ -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); + }); +} diff --git a/services/mana-credits/src/routes/health.ts b/services/mana-credits/src/routes/health.ts new file mode 100644 index 000000000..4b139e880 --- /dev/null +++ b/services/mana-credits/src/routes/health.ts @@ -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() }) +); diff --git a/services/mana-credits/src/routes/internal.ts b/services/mana-credits/src/routes/internal.ts new file mode 100644 index 000000000..9d0e33803 --- /dev/null +++ b/services/mana-credits/src/routes/internal.ts @@ -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); + }); +} diff --git a/services/mana-credits/src/routes/stripe-webhook.ts b/services/mana-credits/src/routes/stripe-webhook.ts new file mode 100644 index 000000000..e52405423 --- /dev/null +++ b/services/mana-credits/src/routes/stripe-webhook.ts @@ -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 }); + }); +} diff --git a/services/mana-credits/src/services/credits.ts b/services/mana-credits/src/services/credits.ts new file mode 100644 index 000000000..9e121a1b7 --- /dev/null +++ b/services/mana-credits/src/services/credits.ts @@ -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; +} + +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 + ) { + 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; + } +} diff --git a/services/mana-credits/src/services/gift-code.ts b/services/mana-credits/src/services/gift-code.ts new file mode 100644 index 000000000..4fb18b6e3 --- /dev/null +++ b/services/mana-credits/src/services/gift-code.ts @@ -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 }; + } +} diff --git a/services/mana-credits/src/services/guild-pool.ts b/services/mana-credits/src/services/guild-pool.ts new file mode 100644 index 000000000..71955e5a7 --- /dev/null +++ b/services/mana-credits/src/services/guild-pool.ts @@ -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; +} + +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`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`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)` + ); + } + } + } +} diff --git a/services/mana-credits/src/services/stripe.ts b/services/mana-credits/src/services/stripe.ts new file mode 100644 index 000000000..f07d92304 --- /dev/null +++ b/services/mana-credits/src/services/stripe.ts @@ -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 { + // 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 + ) { + 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; + }) { + 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); + } +} diff --git a/services/mana-credits/tsconfig.json b/services/mana-credits/tsconfig.json new file mode 100644 index 000000000..354a2c2dd --- /dev/null +++ b/services/mana-credits/tsconfig.json @@ -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"] +}