From af3f21a179ccce36954a631bb3e6db2eb44d2431 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 8 May 2026 18:53:56 +0200 Subject: [PATCH] =?UTF-8?q?chore(cutover):=20remove=20services/mana-credit?= =?UTF-8?q?s/=20=E2=80=94=20moved=20to=20mana-platform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live containers on the Mac Mini build out of `../mana/services/mana-credits/` since the 8-Doppel-Cutover commit (774852ba2). Smoke test green 2026-05-08 — health endpoints, JWKS, login flow, Stripe-webhook all reachable from the new build path. Removing the now-stale duplicate. Was 156K in this repo, gone now. Active code lives in `Code/mana/services/mana-credits/` (siehe ../mana/CLAUDE.md). Co-Authored-By: Claude Opus 4.7 (1M context) --- services/mana-credits/CLAUDE.md | 143 ----- services/mana-credits/Dockerfile | 42 -- services/mana-credits/drizzle.config.ts | 12 - .../drizzle/0001_grant_transaction_type.sql | 23 - services/mana-credits/package.json | 28 - services/mana-credits/src/config.ts | 44 -- services/mana-credits/src/db/connection.ts | 19 - .../mana-credits/src/db/schema/credits.ts | 153 ----- services/mana-credits/src/db/schema/gifts.ts | 118 ---- services/mana-credits/src/db/schema/index.ts | 4 - .../src/db/schema/reservations.ts | 38 -- services/mana-credits/src/db/schema/sync.ts | 39 -- services/mana-credits/src/index.ts | 105 ---- services/mana-credits/src/lib/errors.ts | 43 -- services/mana-credits/src/lib/validation.ts | 101 --- .../mana-credits/src/middleware/jwt-auth.ts | 57 -- .../src/middleware/service-auth.ts | 26 - services/mana-credits/src/routes/admin.ts | 43 -- services/mana-credits/src/routes/credits.ts | 53 -- services/mana-credits/src/routes/gifts.ts | 62 -- services/mana-credits/src/routes/health.ts | 5 - services/mana-credits/src/routes/internal.ts | 85 --- .../mana-credits/src/routes/stripe-webhook.ts | 54 -- services/mana-credits/src/routes/sync.ts | 34 - services/mana-credits/src/services/credits.ts | 592 ------------------ .../mana-credits/src/services/gift-code.ts | 319 ---------- services/mana-credits/src/services/stripe.ts | 89 --- .../mana-credits/src/services/sync-billing.ts | 356 ----------- services/mana-credits/tsconfig.json | 17 - 29 files changed, 2704 deletions(-) delete mode 100644 services/mana-credits/CLAUDE.md delete mode 100644 services/mana-credits/Dockerfile delete mode 100644 services/mana-credits/drizzle.config.ts delete mode 100644 services/mana-credits/drizzle/0001_grant_transaction_type.sql delete mode 100644 services/mana-credits/package.json delete mode 100644 services/mana-credits/src/config.ts delete mode 100644 services/mana-credits/src/db/connection.ts delete mode 100644 services/mana-credits/src/db/schema/credits.ts delete mode 100644 services/mana-credits/src/db/schema/gifts.ts delete mode 100644 services/mana-credits/src/db/schema/index.ts delete mode 100644 services/mana-credits/src/db/schema/reservations.ts delete mode 100644 services/mana-credits/src/db/schema/sync.ts delete mode 100644 services/mana-credits/src/index.ts delete mode 100644 services/mana-credits/src/lib/errors.ts delete mode 100644 services/mana-credits/src/lib/validation.ts delete mode 100644 services/mana-credits/src/middleware/jwt-auth.ts delete mode 100644 services/mana-credits/src/middleware/service-auth.ts delete mode 100644 services/mana-credits/src/routes/admin.ts delete mode 100644 services/mana-credits/src/routes/credits.ts delete mode 100644 services/mana-credits/src/routes/gifts.ts delete mode 100644 services/mana-credits/src/routes/health.ts delete mode 100644 services/mana-credits/src/routes/internal.ts delete mode 100644 services/mana-credits/src/routes/stripe-webhook.ts delete mode 100644 services/mana-credits/src/routes/sync.ts delete mode 100644 services/mana-credits/src/services/credits.ts delete mode 100644 services/mana-credits/src/services/gift-code.ts delete mode 100644 services/mana-credits/src/services/stripe.ts delete mode 100644 services/mana-credits/src/services/sync-billing.ts delete mode 100644 services/mana-credits/tsconfig.json diff --git a/services/mana-credits/CLAUDE.md b/services/mana-credits/CLAUDE.md deleted file mode 100644 index 0d3f99f7a..000000000 --- a/services/mana-credits/CLAUDE.md +++ /dev/null @@ -1,143 +0,0 @@ -# mana-credits - -Standalone credit management service for the Mana ecosystem. Extracted from mana-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-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: 3061 - -## API Endpoints - -### Personal Credits (JWT auth) - -| Method | Path | Description | -|--------|------|-------------| -| GET | `/api/v1/credits/balance` | Get personal balance | -| POST | `/api/v1/credits/use` | Use credits | -| GET | `/api/v1/credits/transactions` | Transaction history | -| GET | `/api/v1/credits/purchases` | Purchase history | -| GET | `/api/v1/credits/packages` | Available packages | -| POST | `/api/v1/credits/purchase` | Initiate Stripe purchase | -| GET | `/api/v1/credits/purchase/:id` | Purchase status | - -### Sync Billing (JWT auth) - -| Method | Path | Description | -|--------|------|-------------| -| GET | `/api/v1/sync/status` | Sync subscription status | -| POST | `/api/v1/sync/activate` | Activate sync (body: `{ interval }`) | -| POST | `/api/v1/sync/deactivate` | Deactivate sync | -| POST | `/api/v1/sync/change-interval` | Change billing interval | - -### 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) | - -### Admin (JWT auth + `role=admin`) - -| Method | Path | Description | -|--------|------|-------------| -| GET | `/api/v1/admin/sync/:userId` | Get sync status for any user | -| POST | `/api/v1/admin/sync/:userId/gift` | Grant Cloud Sync as a gift (no credits charged, no recurring billing) | -| DELETE | `/api/v1/admin/sync/:userId/gift` | Revoke a sync gift (deactivates sync) | - -Gifted subscriptions have `is_gifted=true` and are skipped by the billing cron — they stay active indefinitely until revoked. The user-facing `/activate` and `/deactivate` endpoints refuse to touch gifted rows. - -### 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 (one-shot debit) | -| POST | `/api/v1/internal/credits/refund` | Refund credits (unrelated to reservations) | -| POST | `/api/v1/internal/credits/init` | Initialize balance | -| POST | `/api/v1/internal/credits/reserve` | 2-phase debit: reserve (body: `{ userId, amount, reason }`) → returns `{ reservationId, balance }` | -| POST | `/api/v1/internal/credits/commit` | 2-phase debit: commit (body: `{ reservationId, description? }`) → ledger entry | -| POST | `/api/v1/internal/credits/refund-reservation` | 2-phase debit: refund (body: `{ reservationId }`) → restore balance | -| POST | `/api/v1/internal/gifts/redeem-pending` | Auto-redeem on registration | -| GET | `/api/v1/internal/sync/status/:userId` | Sync status for server check | -| POST | `/api/v1/internal/sync/charge-recurring` | Cron trigger for billing | - -### Webhooks - -| Method | Path | Description | -|--------|------|-------------| -| POST | `/api/v1/webhooks/stripe` | Stripe payment events | - -## Environment Variables - -```env -PORT=3061 -DATABASE_URL=postgresql://mana:devpassword@localhost:5432/mana_credits -MANA_AUTH_URL=http://localhost:3001 -MANA_SERVICE_KEY=dev-service-key -BASE_URL=http://localhost:3061 -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, reservations, gift_codes, gift_redemptions, sync_subscriptions - -## 2-Phase Debit (Reserve/Commit/Refund) - -For services that need to charge only after a downstream call succeeds (e.g. mana-research fanning out to paid API providers), use the `/internal/credits/{reserve,commit,refund-reservation}` flow: - -1. `reserve` — atomically deducts balance, creates row in `credits.reservations` with status `reserved`. Returns `reservationId`. -2. `commit` — marks reservation `committed`, writes transaction ledger entry. -3. `refund-reservation` — marks reservation `refunded`, restores balance. - -One-shot `use` remains for synchronous operations that charge immediately. - -## Credit Operations - -Credits are only charged for operations that cost real money: -- **AI operations** (2-25 credits): Chat with GPT-4/Claude/Gemini, image generation, research, food/plant analysis -- **Premium features** (1-3 credits): PDF export, bulk import, premium themes -- **Cloud Sync** (30 credits/month, 90/quarter, 360/year): Multi-device sync via mana-sync - -Local-first CRUD operations (tasks, events, contacts, etc.) are **free** — they happen in IndexedDB with no server cost. - -## Sync Billing - -Cloud Sync is a monthly credit subscription. Users start in local-only mode and opt-in via Settings. Billing intervals: monthly (30), quarterly (90), yearly (360). 1 Credit = 1 Cent. - -When credits run out, sync is paused (not deleted). Local data is preserved. User sees an in-app banner and can reactivate after topping up credits. - -**Gifted sync**: Admins can grant sync via `POST /api/v1/admin/sync/:userId/gift`. Gifted rows (`is_gifted=true`) are immune to the billing cron and never get paused for insufficient credits. Revoke with `DELETE /api/v1/admin/sync/:userId/gift`. - -## Gift Types - -Two gift types: `simple` (anyone with code can redeem) and `personalized` (auto-redeemed when target email registers). Each gift is single-use. diff --git a/services/mana-credits/Dockerfile b/services/mana-credits/Dockerfile deleted file mode 100644 index 498ba67e0..000000000 --- a/services/mana-credits/Dockerfile +++ /dev/null @@ -1,42 +0,0 @@ -# Install stage: use node + pnpm to resolve workspace dependencies -FROM node:22-alpine AS installer - -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -WORKDIR /app - -# Copy workspace structure -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY services/mana-credits/package.json ./services/mana-credits/ -COPY packages/shared-hono ./packages/shared-hono -COPY packages/shared-logger ./packages/shared-logger -COPY packages/shared-types ./packages/shared-types - -# Install only mana-credits-service and its workspace deps. -# Note the suffix — the workspace name is `@mana/credits-service`, not -# `@mana/credits` (which is a different package under packages/credits). -RUN pnpm install --filter @mana/credits-service... --no-frozen-lockfile --ignore-scripts - -# Runtime stage: bun -FROM oven/bun:1 AS production - -WORKDIR /app - -# Copy installed deps from installer stage -COPY --from=installer /app/node_modules ./node_modules -COPY --from=installer /app/services/mana-credits/node_modules ./services/mana-credits/node_modules -COPY --from=installer /app/packages ./packages - -# Copy source -COPY services/mana-credits/package.json ./services/mana-credits/ -COPY services/mana-credits/src ./services/mana-credits/src -COPY services/mana-credits/tsconfig.json services/mana-credits/drizzle.config.ts ./services/mana-credits/ - -WORKDIR /app/services/mana-credits - -EXPOSE 3061 - -HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ - CMD bun -e "fetch('http://localhost:3061/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 deleted file mode 100644 index 1dd8c8b19..000000000 --- a/services/mana-credits/drizzle.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineConfig } from 'drizzle-kit'; - -export default defineConfig({ - schema: './src/db/schema/*.ts', - out: './drizzle', - dialect: 'postgresql', - dbCredentials: { - url: - process.env.DATABASE_URL || 'postgresql://mana:devpassword@localhost:5432/mana_platform', - }, - schemaFilter: ['credits', 'gifts'], -}); diff --git a/services/mana-credits/drizzle/0001_grant_transaction_type.sql b/services/mana-credits/drizzle/0001_grant_transaction_type.sql deleted file mode 100644 index 6889469be..000000000 --- a/services/mana-credits/drizzle/0001_grant_transaction_type.sql +++ /dev/null @@ -1,23 +0,0 @@ --- 0001_grant_transaction_type.sql --- --- Phase 3.A.1 von docs/plans/feedback-rewards-and-identity.md. --- --- Erweitert credits.transaction_type um 'grant' für Reward-Auszahlungen --- (mana-analytics ruft /internal/credits/grant für +5 / +500 / +25 --- Belohnungen auf). Plus ein partial-Index auf metadata.referenceId, --- damit Idempotency-Lookup beim Re-Grant in O(log n) läuft. --- --- Apply manually before next `pnpm db:push`: --- psql "$DATABASE_URL" -f services/mana-credits/drizzle/0001_grant_transaction_type.sql --- --- Idempotent via IF NOT EXISTS / ADD VALUE IF NOT EXISTS. - -BEGIN; - -ALTER TYPE public.transaction_type ADD VALUE IF NOT EXISTS 'grant'; - -CREATE INDEX IF NOT EXISTS transactions_reference_id_idx - ON credits.transactions ((metadata ->> 'referenceId')) - WHERE metadata ->> 'referenceId' IS NOT NULL; - -COMMIT; diff --git a/services/mana-credits/package.json b/services/mana-credits/package.json deleted file mode 100644 index a16102256..000000000 --- a/services/mana-credits/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@mana/credits-service", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "bun run --hot 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": { - "@mana/shared-hono": "workspace:*", - "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 deleted file mode 100644 index accfcc2ae..000000000 --- a/services/mana-credits/src/config.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * 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 || '3061', 10), - databaseUrl: requiredEnv( - 'DATABASE_URL', - 'postgresql://mana:devpassword@localhost:5432/mana_platform' - ), - manaAuthUrl: requiredEnv('MANA_AUTH_URL', 'http://localhost:3001'), - serviceKey: requiredEnv('MANA_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 deleted file mode 100644 index aa63e328e..000000000 --- a/services/mana-credits/src/db/connection.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * 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 deleted file mode 100644 index 4c8d10988..000000000 --- a/services/mana-credits/src/db/schema/credits.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Credits Schema — Personal balance, transactions, packages, purchases - * - * Adapted from mana-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 ────────────────────────────────────────────────── - -// Enum values must mirror cross-service consumers. New values need a -// hand-authored SQL migration (drizzle-kit push won't add enum members -// reliably across mana-credits' own pgEnum installs). -export const transactionTypeEnum = pgEnum('transaction_type', [ - 'purchase', - 'usage', - 'refund', - 'gift', - 'grant', -]); - -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(), - 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), - }) -); - -/** 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 deleted file mode 100644 index 2ad3192b1..000000000 --- a/services/mana-credits/src/db/schema/gifts.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Gifts Schema — Gift codes, redemptions - * - * Adapted from mana-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']); - -export const giftCodeStatusEnum = pgEnum('gift_code_status', [ - 'active', - 'depleted', - 'expired', - 'cancelled', - 'refunded', -]); - -export const giftRedemptionStatusEnum = pgEnum('gift_redemption_status', [ - 'success', - '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(), - redeemed: integer('redeemed').notNull().default(0), // 0 = unclaimed, 1 = claimed - - // Type and status - type: giftCodeTypeEnum('type').notNull().default('simple'), - status: giftCodeStatusEnum('status').notNull().default('active'), - - // Personalization - targetEmail: text('target_email'), - - // 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), - - 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, - maxMessageLength: 500, - defaultExpirationDays: 90, -} as const; diff --git a/services/mana-credits/src/db/schema/index.ts b/services/mana-credits/src/db/schema/index.ts deleted file mode 100644 index 8bcdc5156..000000000 --- a/services/mana-credits/src/db/schema/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './credits'; -export * from './gifts'; -export * from './sync'; -export * from './reservations'; diff --git a/services/mana-credits/src/db/schema/reservations.ts b/services/mana-credits/src/db/schema/reservations.ts deleted file mode 100644 index 6df1b5a3d..000000000 --- a/services/mana-credits/src/db/schema/reservations.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Credit Reservations — 2-phase debit for services that need to charge only - * after a downstream call succeeds (e.g. mana-research provider fan-out). - * - * Flow: - * reserve() — deduct balance atomically, row with status='reserved' - * commit() — finalize, row becomes 'committed', ledger entry written - * refund() — restore balance, row becomes 'refunded' - */ - -import { pgEnum, text, timestamp, integer, uuid, index } from 'drizzle-orm/pg-core'; -import { creditsSchema } from './credits'; - -export const reservationStatusEnum = pgEnum('reservation_status', [ - 'reserved', - 'committed', - 'refunded', -]); - -export const creditReservations = creditsSchema.table( - 'reservations', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - amount: integer('amount').notNull(), - reason: text('reason').notNull(), - status: reservationStatusEnum('status').default('reserved').notNull(), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - resolvedAt: timestamp('resolved_at', { withTimezone: true }), - }, - (t) => ({ - userIdx: index('reservations_user_id_idx').on(t.userId), - statusIdx: index('reservations_status_idx').on(t.status), - }) -); - -export type CreditReservation = typeof creditReservations.$inferSelect; -export type NewCreditReservation = typeof creditReservations.$inferInsert; diff --git a/services/mana-credits/src/db/schema/sync.ts b/services/mana-credits/src/db/schema/sync.ts deleted file mode 100644 index ef0caa76e..000000000 --- a/services/mana-credits/src/db/schema/sync.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Sync Billing Schema — sync subscription tracking - */ - -import { text, integer, boolean, timestamp, pgEnum } from 'drizzle-orm/pg-core'; -import { creditsSchema } from './credits'; - -// ─── Enums ────────────────────────────────────────────────── - -export const syncBillingIntervalEnum = pgEnum('sync_billing_interval', [ - 'monthly', - 'quarterly', - 'yearly', -]); - -// ─── Tables ───────────────────────────────────────────────── - -/** Sync subscriptions — one per user, tracks billing state */ -export const syncSubscriptions = creditsSchema.table('sync_subscriptions', { - userId: text('user_id').primaryKey(), - active: boolean('active').default(false).notNull(), - billingInterval: syncBillingIntervalEnum('billing_interval').notNull().default('monthly'), - amountCharged: integer('amount_charged').notNull().default(30), - activatedAt: timestamp('activated_at', { withTimezone: true }), - nextChargeAt: timestamp('next_charge_at', { withTimezone: true }), - pausedAt: timestamp('paused_at', { withTimezone: true }), - // Gift flag: when true, billing cron skips this row and `active` stays on indefinitely. - // Set via admin endpoints; immune to insufficient-credits pauses. - isGifted: boolean('is_gifted').default(false).notNull(), - giftedBy: text('gifted_by'), - giftedAt: timestamp('gifted_at', { withTimezone: true }), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), -}); - -// ─── Type Exports ─────────────────────────────────────────── - -export type SyncSubscription = typeof syncSubscriptions.$inferSelect; -export type NewSyncSubscription = typeof syncSubscriptions.$inferInsert; diff --git a/services/mana-credits/src/index.ts b/services/mana-credits/src/index.ts deleted file mode 100644 index 5316ffed7..000000000 --- a/services/mana-credits/src/index.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * mana-credits — Standalone credit management service - * - * Hono + Bun runtime. Extracted from mana-auth. - * Handles: personal credits, gift codes, sync billing, Stripe payments. - */ - -import { Hono } from 'hono'; -import { cors } from 'hono/cors'; -import { loadConfig } from './config'; -import { getDb } from './db/connection'; -import { serviceErrorHandler as errorHandler } from '@mana/shared-hono'; -import { jwtAuth } from './middleware/jwt-auth'; -import { serviceAuth } from './middleware/service-auth'; -import { StripeService } from './services/stripe'; -import { CreditsService } from './services/credits'; -import { GiftCodeService } from './services/gift-code'; -import { SyncBillingService } from './services/sync-billing'; -import { healthRoutes } from './routes/health'; -import { createCreditsRoutes } from './routes/credits'; -import { createGiftRoutes } from './routes/gifts'; -import { createSyncRoutes } from './routes/sync'; -import { createAdminRoutes } from './routes/admin'; -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 creditsService = new CreditsService(db, stripeService); -const giftCodeService = new GiftCodeService(db, config.baseUrl); -const syncBillingService = new SyncBillingService(db, creditsService); - -// ─── 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.use('/api/v1/sync/*', jwtAuth(config.manaAuthUrl)); -app.route('/api/v1/sync', createSyncRoutes(syncBillingService)); - -// Admin routes (JWT auth + admin role check inside) -app.use('/api/v1/admin/*', jwtAuth(config.manaAuthUrl)); -app.route('/api/v1/admin', createAdminRoutes(syncBillingService)); - -// 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, syncBillingService) -); - -// Stripe webhooks (verified by signature, no auth middleware) -app.route( - '/api/v1/webhooks', - createWebhookRoutes(stripeService, creditsService, config.stripe.webhookSecret) -); - -// ─── Cron: Daily sync billing charge ──────────────────────── - -const CHARGE_INTERVAL_MS = 60 * 60 * 1000; // Check every hour -setInterval(async () => { - try { - const result = await syncBillingService.chargeRecurring(); - if (result.total > 0) { - console.log( - `[sync-billing] Recurring charge: ${result.charged} charged, ${result.paused} paused, ${result.errors} errors` - ); - } - } catch (err) { - console.error('[sync-billing] Recurring charge failed:', err); - } -}, CHARGE_INTERVAL_MS); - -// ─── 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 deleted file mode 100644 index d3b2c3392..000000000 --- a/services/mana-credits/src/lib/errors.ts +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 6b7c845c3..000000000 --- a/services/mana-credits/src/lib/validation.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * 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), - 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), -}); - -// ─── Gifts ────────────────────────────────────────────────── - -export const createGiftSchema = z.object({ - totalCredits: z.number().int().positive().min(1).max(10000), - type: z.enum(['simple', 'personalized']).default('simple'), - targetEmail: z.string().email().optional(), - message: z.string().max(500).optional(), - expirationDays: z.number().int().positive().optional(), -}); - -export const redeemGiftSchema = z.object({ - sourceAppId: z.string().optional(), -}); - -// ─── Sync ────────────────────────────────────────────────── - -export const activateSyncSchema = z.object({ - interval: z.enum(['monthly', 'quarterly', 'yearly']).default('monthly'), -}); - -export const changeSyncIntervalSchema = z.object({ - interval: z.enum(['monthly', 'quarterly', 'yearly']), -}); - -// ─── 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), - 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(), -}); - -export const internalGrantSchema = z.object({ - userId: z.string().min(1), - amount: z.number().int().positive(), - reason: z.string().min(1).max(64), - referenceId: z.string().min(1).max(128), - description: z.string().max(256).optional(), -}); - -// ─── Reservations (2-phase debit) ────────────────────────── - -export const internalReserveSchema = z.object({ - userId: z.string().min(1), - amount: z.number().int().positive(), - reason: z.string().min(1).max(200), -}); - -export const internalCommitSchema = z.object({ - reservationId: z.string().uuid(), - description: z.string().max(500).optional(), -}); - -export const internalRefundReservationSchema = z.object({ - reservationId: z.string().uuid(), -}); diff --git a/services/mana-credits/src/middleware/jwt-auth.ts b/services/mana-credits/src/middleware/jwt-auth.ts deleted file mode 100644 index 894f2aad3..000000000 --- a/services/mana-credits/src/middleware/jwt-auth.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * JWT Authentication Middleware - * - * Validates Bearer tokens via JWKS from mana-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: 'mana', - }); - - 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 deleted file mode 100644 index a1012a11d..000000000 --- a/services/mana-credits/src/middleware/service-auth.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * 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/admin.ts b/services/mana-credits/src/routes/admin.ts deleted file mode 100644 index 1695249bb..000000000 --- a/services/mana-credits/src/routes/admin.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Admin routes — privileged ops (JWT auth + admin role check). - * - * Currently exposes sync-gift management. Extend here as more - * admin-scoped credit operations are needed. - */ - -import { Hono } from 'hono'; -import type { SyncBillingService } from '../services/sync-billing'; -import type { AuthUser } from '../middleware/jwt-auth'; - -export function createAdminRoutes(syncBillingService: SyncBillingService) { - const app = new Hono<{ Variables: { user: AuthUser } }>(); - - app.use('*', async (c, next) => { - const user = c.get('user'); - if (user.role !== 'admin') { - return c.json({ error: 'Forbidden', message: 'Admin access required' }, 403); - } - await next(); - }); - - app.post('/sync/:userId/gift', async (c) => { - const { userId } = c.req.param(); - const admin = c.get('user'); - const result = await syncBillingService.grantSyncGift(userId, admin.userId); - return c.json(result); - }); - - app.delete('/sync/:userId/gift', async (c) => { - const { userId } = c.req.param(); - const result = await syncBillingService.revokeSyncGift(userId); - return c.json(result); - }); - - app.get('/sync/:userId', async (c) => { - const { userId } = c.req.param(); - const status = await syncBillingService.getSyncStatus(userId); - return c.json(status); - }); - - return app; -} diff --git a/services/mana-credits/src/routes/credits.ts b/services/mana-credits/src/routes/credits.ts deleted file mode 100644 index 91fb31253..000000000 --- a/services/mana-credits/src/routes/credits.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * 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.useCredits(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 deleted file mode 100644 index 8e022a1d4..000000000 --- a/services/mana-credits/src/routes/gifts.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * 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.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/health.ts b/services/mana-credits/src/routes/health.ts deleted file mode 100644 index 4b139e880..000000000 --- a/services/mana-credits/src/routes/health.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 0d6b09819..000000000 --- a/services/mana-credits/src/routes/internal.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * 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 { SyncBillingService } from '../services/sync-billing'; -import { - internalUseCreditsSchema, - internalRefundSchema, - internalInitSchema, - internalRedeemPendingSchema, - internalReserveSchema, - internalCommitSchema, - internalRefundReservationSchema, - internalGrantSchema, -} from '../lib/validation'; - -export function createInternalRoutes( - creditsService: CreditsService, - giftCodeService: GiftCodeService, - syncBillingService: SyncBillingService -) { - 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.useCredits(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/grant', async (c) => { - const body = internalGrantSchema.parse(await c.req.json()); - const result = await creditsService.grantCredits(body); - 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('/credits/reserve', async (c) => { - const body = internalReserveSchema.parse(await c.req.json()); - const result = await creditsService.reserve(body.userId, body.amount, body.reason); - return c.json(result); - }) - .post('/credits/commit', async (c) => { - const body = internalCommitSchema.parse(await c.req.json()); - const result = await creditsService.commitReservation(body.reservationId, body.description); - return c.json(result); - }) - .post('/credits/refund-reservation', async (c) => { - const body = internalRefundReservationSchema.parse(await c.req.json()); - const result = await creditsService.refundReservation(body.reservationId); - return c.json(result); - }) - .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); - }) - .get('/sync/status/:userId', async (c) => { - const status = await syncBillingService.getSyncStatus(c.req.param('userId')); - return c.json(status); - }) - .post('/sync/charge-recurring', async (c) => { - const result = await syncBillingService.chargeRecurring(); - return c.json(result); - }); -} diff --git a/services/mana-credits/src/routes/stripe-webhook.ts b/services/mana-credits/src/routes/stripe-webhook.ts deleted file mode 100644 index e52405423..000000000 --- a/services/mana-credits/src/routes/stripe-webhook.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * 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/routes/sync.ts b/services/mana-credits/src/routes/sync.ts deleted file mode 100644 index 288e75435..000000000 --- a/services/mana-credits/src/routes/sync.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Sync billing routes — user-facing endpoints (JWT auth) - */ - -import { Hono } from 'hono'; -import type { SyncBillingService } from '../services/sync-billing'; -import type { AuthUser } from '../middleware/jwt-auth'; -import { activateSyncSchema, changeSyncIntervalSchema } from '../lib/validation'; - -export function createSyncRoutes(syncBillingService: SyncBillingService) { - return new Hono<{ Variables: { user: AuthUser } }>() - .get('/status', async (c) => { - const user = c.get('user'); - const status = await syncBillingService.getSyncStatus(user.userId); - return c.json(status); - }) - .post('/activate', async (c) => { - const user = c.get('user'); - const body = activateSyncSchema.parse(await c.req.json()); - const result = await syncBillingService.activateSync(user.userId, body.interval); - return c.json(result); - }) - .post('/deactivate', async (c) => { - const user = c.get('user'); - const result = await syncBillingService.deactivateSync(user.userId); - return c.json(result); - }) - .post('/change-interval', async (c) => { - const user = c.get('user'); - const body = changeSyncIntervalSchema.parse(await c.req.json()); - const result = await syncBillingService.changeBillingInterval(user.userId, body.interval); - return c.json(result); - }); -} diff --git a/services/mana-credits/src/services/credits.ts b/services/mana-credits/src/services/credits.ts deleted file mode 100644 index df39ea4e7..000000000 --- a/services/mana-credits/src/services/credits.ts +++ /dev/null @@ -1,592 +0,0 @@ -/** - * Credits Service — Personal balance management - * - * Ported from mana-auth CreditsService. - * Handles balance CRUD, credit usage, purchases, and transaction ledger. - */ - -import { eq, and, desc, sql } from 'drizzle-orm'; -import { balances, transactions, purchases, packages, usageStats } from '../db/schema/credits'; -import { creditReservations } from '../db/schema/reservations'; -import type { Database } from '../db/connection'; -import type { StripeService } from './stripe'; -import { - BadRequestError, - NotFoundError, - ConflictError, - InsufficientCreditsError, -} from '../lib/errors'; - -interface UseCreditsParams { - amount: number; - appId: string; - description: string; - idempotencyKey?: string; - metadata?: Record; -} - -export class CreditsService { - constructor( - private db: Database, - private stripeService: StripeService - ) {} - - 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 }, - }; - }); - } - - 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 } }; - }); - } - - /** - * Grant credits to a user as a reward (no money changed hands). - * Idempotent on `referenceId`: if a previous grant with the same - * referenceId already landed, returns `alreadyGranted: true` without - * mutating balance. - * - * Used by mana-analytics to drop +5 Credits for every quality - * feedback submission and +500 Credits when a wish ships, plus +25 - * to each reactioner whose vote nudged the wish toward 'completed'. - */ - async grantCredits(params: { - userId: string; - amount: number; - reason: string; - referenceId: string; - description?: string; - }) { - if (params.amount <= 0) throw new BadRequestError('amount must be > 0'); - if (!params.referenceId) throw new BadRequestError('referenceId is required for idempotency'); - - // Idempotency: short-circuit if this referenceId already produced a grant. - const existing = await this.db - .select({ id: transactions.id, balanceAfter: transactions.balanceAfter }) - .from(transactions) - .where( - and( - eq(transactions.userId, params.userId), - eq(transactions.type, 'grant'), - sql`${transactions.metadata}->>'referenceId' = ${params.referenceId}` - ) - ) - .limit(1); - - if (existing.length > 0) { - return { ok: true, alreadyGranted: true, newBalance: existing[0].balanceAfter }; - } - - return await this.db.transaction(async (tx) => { - // Ensure balance row exists. - const [current] = await tx - .select() - .from(balances) - .where(eq(balances.userId, params.userId)) - .for('update') - .limit(1); - - let balanceBefore: number; - let totalEarnedBefore: number; - let version: number; - if (!current) { - const [created] = await tx - .insert(balances) - .values({ userId: params.userId, balance: 0, totalEarned: 0, totalSpent: 0 }) - .returning(); - balanceBefore = created.balance; - totalEarnedBefore = created.totalEarned; - version = created.version; - } else { - balanceBefore = current.balance; - totalEarnedBefore = current.totalEarned; - version = current.version; - } - - const newBalance = balanceBefore + params.amount; - - await tx - .update(balances) - .set({ - balance: newBalance, - totalEarned: totalEarnedBefore + params.amount, // grants count as earned - version: version + 1, - updatedAt: new Date(), - }) - .where(and(eq(balances.userId, params.userId), eq(balances.version, version))); - - const [transaction] = await tx - .insert(transactions) - .values({ - userId: params.userId, - type: 'grant', - status: 'completed', - amount: params.amount, - balanceBefore, - balanceAfter: newBalance, - appId: 'community', - description: params.description ?? `Reward: ${params.reason}`, - metadata: { reason: params.reason, referenceId: params.referenceId }, - completedAt: new Date(), - }) - .returning(); - - return { ok: true, alreadyGranted: false, newBalance, transactionId: transaction.id }; - }); - } - - 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; - } - - // ─── 2-phase debit (reserve / commit / refund) ───────────── - // Used by mana-research for provider calls that should only be charged - // after the downstream API succeeds. See services/mana-research. - - async reserve(userId: string, amount: number, reason: string) { - if (amount <= 0) throw new BadRequestError('Reservation amount must be positive'); - - 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'); - if (current.balance < amount) { - throw new InsufficientCreditsError(amount, current.balance); - } - - const newBalance = current.balance - amount; - - const updated = await tx - .update(balances) - .set({ - balance: newBalance, - version: current.version + 1, - updatedAt: new Date(), - }) - .where(and(eq(balances.userId, userId), eq(balances.version, current.version))) - .returning(); - - if (updated.length === 0) { - throw new ConflictError('Balance was modified concurrently. Please retry.'); - } - - const [reservation] = await tx - .insert(creditReservations) - .values({ userId, amount, reason, status: 'reserved' }) - .returning(); - - return { - reservationId: reservation.id, - balance: newBalance, - }; - }); - } - - async commitReservation(reservationId: string, description?: string) { - return await this.db.transaction(async (tx) => { - const [reservation] = await tx - .select() - .from(creditReservations) - .where(eq(creditReservations.id, reservationId)) - .for('update') - .limit(1); - - if (!reservation) throw new NotFoundError('Reservation not found'); - if (reservation.status !== 'reserved') { - throw new BadRequestError(`Cannot commit reservation in status: ${reservation.status}`); - } - - await tx - .update(creditReservations) - .set({ status: 'committed', resolvedAt: new Date() }) - .where(eq(creditReservations.id, reservationId)); - - const [balance] = await tx - .select() - .from(balances) - .where(eq(balances.userId, reservation.userId)) - .limit(1); - - const balanceAfter = balance?.balance ?? 0; - const balanceBefore = balanceAfter + reservation.amount; - - await tx - .update(balances) - .set({ - totalSpent: (balance?.totalSpent ?? 0) + reservation.amount, - updatedAt: new Date(), - }) - .where(eq(balances.userId, reservation.userId)); - - const [transaction] = await tx - .insert(transactions) - .values({ - userId: reservation.userId, - type: 'usage', - status: 'completed', - amount: -reservation.amount, - balanceBefore, - balanceAfter, - appId: reservation.reason.split(':')[0] || 'mana-research', - description: description ?? reservation.reason, - metadata: { reservationId: reservation.id }, - completedAt: new Date(), - }) - .returning(); - - return { success: true, transactionId: transaction.id }; - }); - } - - async refundReservation(reservationId: string) { - return await this.db.transaction(async (tx) => { - const [reservation] = await tx - .select() - .from(creditReservations) - .where(eq(creditReservations.id, reservationId)) - .for('update') - .limit(1); - - if (!reservation) throw new NotFoundError('Reservation not found'); - if (reservation.status !== 'reserved') { - throw new BadRequestError(`Cannot refund reservation in status: ${reservation.status}`); - } - - await tx - .update(creditReservations) - .set({ status: 'refunded', resolvedAt: new Date() }) - .where(eq(creditReservations.id, reservationId)); - - const [current] = await tx - .select() - .from(balances) - .where(eq(balances.userId, reservation.userId)) - .for('update') - .limit(1); - - if (!current) throw new NotFoundError('User balance not found'); - - const newBalance = current.balance + reservation.amount; - await tx - .update(balances) - .set({ - balance: newBalance, - version: current.version + 1, - updatedAt: new Date(), - }) - .where(and(eq(balances.userId, reservation.userId), eq(balances.version, current.version))); - - return { success: true, balance: newBalance }; - }); - } -} diff --git a/services/mana-credits/src/services/gift-code.ts b/services/mana-credits/src/services/gift-code.ts deleted file mode 100644 index 2974e3b43..000000000 --- a/services/mana-credits/src/services/gift-code.ts +++ /dev/null @@ -1,319 +0,0 @@ -/** - * Gift Code Service — Gift code generation, redemption, cancellation - * - * Simplified: only 'simple' and 'personalized' gift types. - * Each gift is a single-use code (one redeemer gets all credits). - */ - -import { eq, and, desc } from 'drizzle-orm'; -import { - 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'; - targetEmail?: 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' } = 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}` - ); - } - - 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(); - - 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, - type, - targetEmail: params.targetEmail, - message: params.message, - expiresAt, - reservationTransactionId: reservationTx.id, - }) - .returning(); - - return gift; - }); - } - - async redeemGift(code: string, redeemerId: 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.redeemed >= 1) throw new BadRequestError('Gift already claimed'); - - // 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.totalCredits; - - if (balance) { - await tx - .update(balances) - .set({ - balance: newBalance, - totalEarned: balance.totalEarned + gift.totalCredits, - version: balance.version + 1, - updatedAt: new Date(), - }) - .where(eq(balances.userId, redeemerId)); - } else { - await tx.insert(balances).values({ - userId: redeemerId, - balance: gift.totalCredits, - totalEarned: gift.totalCredits, - totalSpent: 0, - }); - } - - // Ledger - const [creditTx] = await tx - .insert(transactions) - .values({ - userId: redeemerId, - type: 'gift', - status: 'completed', - amount: gift.totalCredits, - balanceBefore, - balanceAfter: newBalance, - appId: 'gifts', - description: `Gift redeemed: ${gift.totalCredits} 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.totalCredits, - creditTransactionId: creditTx.id, - sourceAppId, - }); - - // Mark gift as depleted - await tx - .update(giftCodes) - .set({ redeemed: 1, status: 'depleted', updatedAt: new Date() }) - .where(eq(giftCodes.id, gift.id)); - - return { - success: true, - creditsReceived: gift.totalCredits, - 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, - totalCredits: gift.totalCredits, - redeemed: gift.redeemed > 0, - 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'); - - // Only refund if not yet redeemed - const refundAmount = gift.redeemed === 0 ? gift.totalCredits : 0; - - 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, 'auto-registration'); - totalRedeemed += result.creditsReceived; - } catch { - // Skip failed redemptions - } - } - return { redeemed: pendingGifts.length, totalCredits: totalRedeemed }; - } -} diff --git a/services/mana-credits/src/services/stripe.ts b/services/mana-credits/src/services/stripe.ts deleted file mode 100644 index f07d92304..000000000 --- a/services/mana-credits/src/services/stripe.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * 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/src/services/sync-billing.ts b/services/mana-credits/src/services/sync-billing.ts deleted file mode 100644 index 3a1f32a65..000000000 --- a/services/mana-credits/src/services/sync-billing.ts +++ /dev/null @@ -1,356 +0,0 @@ -/** - * Sync Billing Service — manages sync subscriptions and recurring charges - */ - -import { eq, and, lte } from 'drizzle-orm'; -import { syncSubscriptions } from '../db/schema/sync'; -import type { Database } from '../db/connection'; -import type { CreditsService } from './credits'; -import { BadRequestError, NotFoundError, InsufficientCreditsError } from '../lib/errors'; - -type BillingInterval = 'monthly' | 'quarterly' | 'yearly'; - -const MANA_NOTIFY_URL = () => process.env.MANA_NOTIFY_URL || 'http://localhost:3040'; -const SERVICE_KEY = () => process.env.MANA_SERVICE_KEY || ''; - -async function sendPauseNotification(userId: string): Promise { - const key = SERVICE_KEY(); - if (!key) return; - - try { - await fetch(`${MANA_NOTIFY_URL()}/api/v1/notifications/send`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Service-Key': key, - }, - body: JSON.stringify({ - channel: 'email', - appId: 'sync', - userId, - subject: 'Cloud Sync pausiert', - body: 'Dein Cloud Sync wurde pausiert, weil deine Credits nicht ausreichen. Lade Credits auf, um die Synchronisation fortzusetzen.', - data: { type: 'sync-paused', resumeUrl: 'https://mana.how/settings/sync' }, - }), - }); - } catch (err) { - console.error(`[sync-billing] Failed to send pause notification for ${userId}:`, err); - } -} - -const SYNC_PRICES: Record = { - monthly: 30, - quarterly: 90, - yearly: 360, -}; - -function getNextChargeDate(from: Date, interval: BillingInterval): Date { - const next = new Date(from); - switch (interval) { - case 'monthly': - next.setMonth(next.getMonth() + 1); - break; - case 'quarterly': - next.setMonth(next.getMonth() + 3); - break; - case 'yearly': - next.setFullYear(next.getFullYear() + 1); - break; - } - return next; -} - -export class SyncBillingService { - constructor( - private db: Database, - private creditsService: CreditsService - ) {} - - async getSyncStatus(userId: string) { - const [sub] = await this.db - .select() - .from(syncSubscriptions) - .where(eq(syncSubscriptions.userId, userId)) - .limit(1); - - if (!sub) { - return { - active: false, - interval: 'monthly' as BillingInterval, - nextChargeAt: null, - pausedAt: null, - gifted: false, - }; - } - - return { - active: sub.active, - interval: sub.billingInterval as BillingInterval, - nextChargeAt: sub.nextChargeAt?.toISOString() ?? null, - pausedAt: sub.pausedAt?.toISOString() ?? null, - gifted: sub.isGifted, - }; - } - - async activateSync(userId: string, interval: BillingInterval = 'monthly') { - const amount = SYNC_PRICES[interval]; - const now = new Date(); - - // Check if already active - const [existing] = await this.db - .select() - .from(syncSubscriptions) - .where(eq(syncSubscriptions.userId, userId)) - .limit(1); - - if (existing?.isGifted) { - throw new BadRequestError('Sync is already gifted — no activation needed'); - } - - if (existing?.active) { - throw new BadRequestError('Sync is already active'); - } - - // Charge credits - await this.creditsService.useCredits(userId, { - amount, - appId: 'sync', - description: `Cloud Sync activated (${interval})`, - metadata: { interval, type: 'sync_subscription' }, - }); - - const nextChargeAt = getNextChargeDate(now, interval); - - if (existing) { - // Reactivate existing subscription - await this.db - .update(syncSubscriptions) - .set({ - active: true, - billingInterval: interval, - amountCharged: amount, - activatedAt: now, - nextChargeAt, - pausedAt: null, - updatedAt: now, - }) - .where(eq(syncSubscriptions.userId, userId)); - } else { - // Create new subscription - await this.db.insert(syncSubscriptions).values({ - userId, - active: true, - billingInterval: interval, - amountCharged: amount, - activatedAt: now, - nextChargeAt, - }); - } - - return { - success: true, - active: true, - interval, - nextChargeAt: nextChargeAt.toISOString(), - amountCharged: amount, - }; - } - - async deactivateSync(userId: string) { - const [sub] = await this.db - .select() - .from(syncSubscriptions) - .where(eq(syncSubscriptions.userId, userId)) - .limit(1); - - if (!sub || !sub.active) { - throw new BadRequestError('Sync is not active'); - } - - if (sub.isGifted) { - throw new BadRequestError('Sync is gifted — contact support to revoke'); - } - - await this.db - .update(syncSubscriptions) - .set({ - active: false, - nextChargeAt: null, - updatedAt: new Date(), - }) - .where(eq(syncSubscriptions.userId, userId)); - - return { success: true }; - } - - async changeBillingInterval(userId: string, newInterval: BillingInterval) { - const [sub] = await this.db - .select() - .from(syncSubscriptions) - .where(eq(syncSubscriptions.userId, userId)) - .limit(1); - - if (!sub || !sub.active) { - throw new BadRequestError('Sync is not active'); - } - - const newAmount = SYNC_PRICES[newInterval]; - - // Change takes effect at next billing cycle - await this.db - .update(syncSubscriptions) - .set({ - billingInterval: newInterval, - amountCharged: newAmount, - updatedAt: new Date(), - }) - .where(eq(syncSubscriptions.userId, userId)); - - return { - success: true, - interval: newInterval, - amountCharged: newAmount, - effectiveAt: sub.nextChargeAt?.toISOString() ?? null, - }; - } - - /** - * Charge all due sync subscriptions. Called by cron job (daily). - * Returns summary of charges, pauses, and errors. - */ - async chargeRecurring() { - const now = new Date(); - - // Gifted subscriptions are skipped — they never get charged. - const dueSubscriptions = await this.db - .select() - .from(syncSubscriptions) - .where( - and( - eq(syncSubscriptions.active, true), - eq(syncSubscriptions.isGifted, false), - lte(syncSubscriptions.nextChargeAt, now) - ) - ); - - let charged = 0; - let paused = 0; - let errors = 0; - - for (const sub of dueSubscriptions) { - try { - await this.creditsService.useCredits(sub.userId, { - amount: sub.amountCharged, - appId: 'sync', - description: `Cloud Sync renewal (${sub.billingInterval})`, - metadata: { interval: sub.billingInterval, type: 'sync_renewal' }, - }); - - // Update next charge date - const nextChargeAt = getNextChargeDate(now, sub.billingInterval as BillingInterval); - await this.db - .update(syncSubscriptions) - .set({ nextChargeAt, updatedAt: now }) - .where(eq(syncSubscriptions.userId, sub.userId)); - - charged++; - } catch (error) { - if (error instanceof InsufficientCreditsError) { - // Pause subscription - await this.db - .update(syncSubscriptions) - .set({ - active: false, - pausedAt: now, - updatedAt: now, - }) - .where(eq(syncSubscriptions.userId, sub.userId)); - - paused++; - sendPauseNotification(sub.userId).catch(() => {}); - } else { - errors++; - console.error(`[sync-billing] Failed to charge user ${sub.userId}:`, error); - } - } - } - - return { charged, paused, errors, total: dueSubscriptions.length }; - } - - /** - * Grant sync as a gift — no credits charged, no recurring billing. - * Idempotent: re-granting refreshes the giftedAt/giftedBy fields. - */ - async grantSyncGift(userId: string, grantedBy?: string) { - const now = new Date(); - - const [existing] = await this.db - .select() - .from(syncSubscriptions) - .where(eq(syncSubscriptions.userId, userId)) - .limit(1); - - if (existing) { - await this.db - .update(syncSubscriptions) - .set({ - active: true, - isGifted: true, - giftedBy: grantedBy ?? null, - giftedAt: now, - activatedAt: existing.activatedAt ?? now, - nextChargeAt: null, - pausedAt: null, - updatedAt: now, - }) - .where(eq(syncSubscriptions.userId, userId)); - } else { - await this.db.insert(syncSubscriptions).values({ - userId, - active: true, - isGifted: true, - giftedBy: grantedBy ?? null, - giftedAt: now, - activatedAt: now, - nextChargeAt: null, - }); - } - - return { success: true, userId, gifted: true, active: true }; - } - - /** - * Revoke a sync gift. Deactivates sync and clears the gift flag. - * If the user wants sync back, they must activate normally (paying credits). - */ - async revokeSyncGift(userId: string) { - const [sub] = await this.db - .select() - .from(syncSubscriptions) - .where(eq(syncSubscriptions.userId, userId)) - .limit(1); - - if (!sub) { - throw new NotFoundError('No sync subscription found for user'); - } - - if (!sub.isGifted) { - throw new BadRequestError('Sync is not gifted — nothing to revoke'); - } - - await this.db - .update(syncSubscriptions) - .set({ - active: false, - isGifted: false, - giftedBy: null, - giftedAt: null, - nextChargeAt: null, - updatedAt: new Date(), - }) - .where(eq(syncSubscriptions.userId, userId)); - - return { success: true, userId, gifted: false, active: false }; - } -} diff --git a/services/mana-credits/tsconfig.json b/services/mana-credits/tsconfig.json deleted file mode 100644 index 354a2c2dd..000000000 --- a/services/mana-credits/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "outDir": "dist", - "rootDir": "src", - "declaration": true, - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["src/**/*.ts"] -}