mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 05:29:39 +02:00
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:
parent
02bd9d3117
commit
15deaf4e0a
27 changed files with 2373 additions and 0 deletions
100
services/mana-credits/CLAUDE.md
Normal file
100
services/mana-credits/CLAUDE.md
Normal 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
|
||||
18
services/mana-credits/Dockerfile
Normal file
18
services/mana-credits/Dockerfile
Normal 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"]
|
||||
12
services/mana-credits/drizzle.config.ts
Normal file
12
services/mana-credits/drizzle.config.ts
Normal 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'],
|
||||
});
|
||||
27
services/mana-credits/package.json
Normal file
27
services/mana-credits/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
44
services/mana-credits/src/config.ts
Normal file
44
services/mana-credits/src/config.ts
Normal 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(','),
|
||||
},
|
||||
};
|
||||
}
|
||||
19
services/mana-credits/src/db/connection.ts
Normal file
19
services/mana-credits/src/db/connection.ts
Normal 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>;
|
||||
152
services/mana-credits/src/db/schema/credits.ts
Normal file
152
services/mana-credits/src/db/schema/credits.ts
Normal 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;
|
||||
135
services/mana-credits/src/db/schema/gifts.ts
Normal file
135
services/mana-credits/src/db/schema/gifts.ts
Normal 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;
|
||||
79
services/mana-credits/src/db/schema/guilds.ts
Normal file
79
services/mana-credits/src/db/schema/guilds.ts
Normal 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;
|
||||
3
services/mana-credits/src/db/schema/index.ts
Normal file
3
services/mana-credits/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './credits';
|
||||
export * from './gifts';
|
||||
export * from './guilds';
|
||||
82
services/mana-credits/src/index.ts
Normal file
82
services/mana-credits/src/index.ts
Normal 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,
|
||||
};
|
||||
43
services/mana-credits/src/lib/errors.ts
Normal file
43
services/mana-credits/src/lib/errors.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
95
services/mana-credits/src/lib/validation.ts
Normal file
95
services/mana-credits/src/lib/validation.ts
Normal 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(),
|
||||
});
|
||||
29
services/mana-credits/src/middleware/error-handler.ts
Normal file
29
services/mana-credits/src/middleware/error-handler.ts
Normal 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
|
||||
);
|
||||
};
|
||||
57
services/mana-credits/src/middleware/jwt-auth.ts
Normal file
57
services/mana-credits/src/middleware/jwt-auth.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
}
|
||||
26
services/mana-credits/src/middleware/service-auth.ts
Normal file
26
services/mana-credits/src/middleware/service-auth.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
53
services/mana-credits/src/routes/credits.ts
Normal file
53
services/mana-credits/src/routes/credits.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
63
services/mana-credits/src/routes/gifts.ts
Normal file
63
services/mana-credits/src/routes/gifts.ts
Normal 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;
|
||||
}
|
||||
69
services/mana-credits/src/routes/guild.ts
Normal file
69
services/mana-credits/src/routes/guild.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
5
services/mana-credits/src/routes/health.ts
Normal file
5
services/mana-credits/src/routes/health.ts
Normal 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() })
|
||||
);
|
||||
58
services/mana-credits/src/routes/internal.ts
Normal file
58
services/mana-credits/src/routes/internal.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
54
services/mana-credits/src/routes/stripe-webhook.ts
Normal file
54
services/mana-credits/src/routes/stripe-webhook.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
361
services/mana-credits/src/services/credits.ts
Normal file
361
services/mana-credits/src/services/credits.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
367
services/mana-credits/src/services/gift-code.ts
Normal file
367
services/mana-credits/src/services/gift-code.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
316
services/mana-credits/src/services/guild-pool.ts
Normal file
316
services/mana-credits/src/services/guild-pool.ts
Normal 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)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
89
services/mana-credits/src/services/stripe.ts
Normal file
89
services/mana-credits/src/services/stripe.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
17
services/mana-credits/tsconfig.json
Normal file
17
services/mana-credits/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue