diff --git a/services/mana-core-auth/src/app.module.ts b/services/mana-core-auth/src/app.module.ts index 31eccf1fb..5572d5b6a 100644 --- a/services/mana-core-auth/src/app.module.ts +++ b/services/mana-core-auth/src/app.module.ts @@ -8,9 +8,7 @@ import { AdminModule } from './admin/admin.module'; import { AiModule } from './ai/ai.module'; import { ApiKeysModule } from './api-keys/api-keys.module'; import { AuthModule } from './auth/auth.module'; -import { CreditsModule } from './credits/credits.module'; import { FeedbackModule } from './feedback/feedback.module'; -import { GiftsModule } from './gifts/gifts.module'; import { GuildsModule } from './guilds/guilds.module'; import { HealthModule } from './health/health.module'; import { SettingsModule } from './settings/settings.module'; @@ -55,9 +53,7 @@ import { SecurityModule } from './security'; AiModule, ApiKeysModule, AuthModule, - CreditsModule, FeedbackModule, - GiftsModule, GuildsModule, HealthModule, SettingsModule, diff --git a/services/mana-core-auth/src/auth/services/better-auth.service.ts b/services/mana-core-auth/src/auth/services/better-auth.service.ts index 71b46ad17..38ffa37fb 100644 --- a/services/mana-core-auth/src/auth/services/better-auth.service.ts +++ b/services/mana-core-auth/src/auth/services/better-auth.service.ts @@ -19,18 +19,12 @@ import { NotFoundException, ForbiddenException, UnauthorizedException, - Inject, - forwardRef, - Optional, } from '@nestjs/common'; import { LoggerService } from '../../common/logger'; import { ConfigService } from '@nestjs/config'; import { createBetterAuth } from '../better-auth.config'; import type { BetterAuthInstance } from '../better-auth.config'; import { getDb } from '../../db/connection'; -import { balances } from '../../db/schema/credits.schema'; -import { guildPools } from '../../db/schema/guilds.schema'; -import { GiftCodeService } from '../../gifts/services/gift-code.service'; import { hasUser, hasToken, hasMember, hasMembers, hasSession } from '../types/better-auth.types'; import { sourceAppStore } from '../stores/source-app.store'; import { passwordResetRedirectStore } from '../stores/password-reset-redirect.store'; @@ -112,9 +106,6 @@ export class BetterAuthService { constructor( private configService: ConfigService, - @Optional() - @Inject(forwardRef(() => GiftCodeService)) - private giftCodeService: GiftCodeService, loggerService: LoggerService ) { this.logger = loggerService.setContext('BetterAuthService'); diff --git a/services/mana-core-auth/src/credits/credits.controller.spec.ts b/services/mana-core-auth/src/credits/credits.controller.spec.ts deleted file mode 100644 index 1ed16f732..000000000 --- a/services/mana-core-auth/src/credits/credits.controller.spec.ts +++ /dev/null @@ -1,350 +0,0 @@ -/** - * CreditsController Unit Tests - * - * Tests all credits controller endpoints: - * - * B2C (Personal) Endpoints: - * - GET /credits/balance - Get user balance - * - POST /credits/use - Use credits - * - GET /credits/transactions - Get transaction history - * - GET /credits/purchases - Get purchase history - * - GET /credits/packages - Get available packages - * - * B2B (Organization) Endpoints: - * - POST /credits/organization/allocate - Allocate credits to employee - * - GET /credits/organization/:orgId/balance - Get org balance - * - GET /credits/organization/:orgId/employee/:empId/balance - Get employee balance - * - POST /credits/organization/:orgId/use - Use credits with org tracking - */ - -import { Test } from '@nestjs/testing'; -import type { TestingModule } from '@nestjs/testing'; -import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; -import { CreditsController } from './credits.controller'; -import { CreditsService } from './credits.service'; -import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; -import { type CurrentUserData } from '../common/decorators/current-user.decorator'; -import { - mockBalanceFactory, - mockTransactionFactory, - mockPackageFactory, - mockPurchaseFactory, - mockOrganizationBalanceFactory, - mockDtoFactory, -} from '../__tests__/utils/mock-factories'; -import { nanoid } from 'nanoid'; - -describe('CreditsController', () => { - let controller: CreditsController; - let creditsService: jest.Mocked; - - // Common test user data - const mockUser: CurrentUserData = { - userId: 'user-123', - email: 'user@example.com', - role: 'user', - }; - - const mockOrgOwner: CurrentUserData = { - userId: 'owner-456', - email: 'owner@company.com', - role: 'user', - }; - - beforeEach(async () => { - // Create mock CreditsService - const mockCreditsService = { - getBalance: jest.fn(), - useCredits: jest.fn(), - useCreditsWithSource: jest.fn(), - getTransactionHistory: jest.fn(), - getPurchaseHistory: jest.fn(), - getPackages: jest.fn(), - }; - - const module: TestingModule = await Test.createTestingModule({ - controllers: [CreditsController], - providers: [ - { - provide: CreditsService, - useValue: mockCreditsService, - }, - ], - }) - // Override the guard to allow all requests in tests - .overrideGuard(JwtAuthGuard) - .useValue({ canActivate: jest.fn(() => true) }) - .compile(); - - controller = module.get(CreditsController); - creditsService = module.get(CreditsService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - // ============================================================================ - // B2C ENDPOINTS - Personal Credits - // ============================================================================ - - describe('B2C Endpoints', () => { - // -------------------------------------------------------------------------- - // GET /credits/balance - // -------------------------------------------------------------------------- - - describe('GET /credits/balance', () => { - it('should return user balance', async () => { - const expectedBalance = mockBalanceFactory.withBalance(mockUser.userId, 500); - - creditsService.getBalance.mockResolvedValue(expectedBalance); - - const result = await controller.getBalance(mockUser); - - expect(result).toEqual(expectedBalance); - expect(creditsService.getBalance).toHaveBeenCalledWith(mockUser.userId); - }); - - it('should return zero balance for new user', async () => { - const newUserBalance = mockBalanceFactory.create(mockUser.userId, { - balance: 0, - }); - - creditsService.getBalance.mockResolvedValue(newUserBalance); - - const result = await controller.getBalance(mockUser); - - expect(result.balance).toBe(0); - }); - }); - - // -------------------------------------------------------------------------- - // POST /credits/use - // -------------------------------------------------------------------------- - - describe('POST /credits/use', () => { - it('should successfully use credits', async () => { - const useCreditsDto = mockDtoFactory.useCredits({ - amount: 10, - appId: 'memoro', - description: 'AI transcription', - }); - - const expectedResult = { - success: true, - transaction: mockTransactionFactory.create(mockUser.userId, { - amount: -10, - appId: 'memoro', - }), - newBalance: 90, - }; - - creditsService.useCreditsWithSource.mockResolvedValue(expectedResult as any); - - const result = await controller.useCredits(mockUser, useCreditsDto); - - expect(result).toEqual(expectedResult); - expect(creditsService.useCreditsWithSource).toHaveBeenCalledWith( - mockUser.userId, - useCreditsDto - ); - }); - - it('should pass idempotency key for duplicate prevention', async () => { - const idempotencyKey = `idempotency-${nanoid()}`; - const useCreditsDto = mockDtoFactory.useCredits({ - amount: 25, - appId: 'chat', - description: 'Message generation', - idempotencyKey, - }); - - creditsService.useCreditsWithSource.mockResolvedValue({ success: true } as any); - - await controller.useCredits(mockUser, useCreditsDto); - - expect(creditsService.useCreditsWithSource).toHaveBeenCalledWith( - mockUser.userId, - expect.objectContaining({ idempotencyKey }) - ); - }); - - it('should propagate BadRequestException for insufficient credits', async () => { - const useCreditsDto = mockDtoFactory.useCredits({ - amount: 1000, - appId: 'picture', - description: 'Image generation', - }); - - creditsService.useCreditsWithSource.mockRejectedValue( - new BadRequestException('Insufficient credits') - ); - - await expect(controller.useCredits(mockUser, useCreditsDto)).rejects.toThrow( - BadRequestException - ); - }); - - it('should handle metadata in credit usage', async () => { - const useCreditsDto = mockDtoFactory.useCredits({ - amount: 5, - appId: 'wisekeep', - description: 'Video analysis', - metadata: { - videoId: 'vid-123', - duration: 120, - model: 'gpt-4', - }, - }); - - creditsService.useCreditsWithSource.mockResolvedValue({ success: true } as any); - - await controller.useCredits(mockUser, useCreditsDto); - - expect(creditsService.useCreditsWithSource).toHaveBeenCalledWith( - mockUser.userId, - expect.objectContaining({ - metadata: { - videoId: 'vid-123', - duration: 120, - model: 'gpt-4', - }, - }) - ); - }); - }); - - // -------------------------------------------------------------------------- - // GET /credits/transactions - // -------------------------------------------------------------------------- - - describe('GET /credits/transactions', () => { - it('should return transaction history with default pagination', async () => { - const transactions = mockTransactionFactory.createMany(mockUser.userId, 5); - - creditsService.getTransactionHistory.mockResolvedValue(transactions as any); - - const result = await controller.getTransactionHistory(mockUser); - - expect(result).toEqual(transactions); - expect(creditsService.getTransactionHistory).toHaveBeenCalledWith( - mockUser.userId, - undefined, - undefined - ); - }); - - it('should pass limit parameter', async () => { - const limit = 10; - - creditsService.getTransactionHistory.mockResolvedValue([]); - - await controller.getTransactionHistory(mockUser, limit); - - expect(creditsService.getTransactionHistory).toHaveBeenCalledWith( - mockUser.userId, - limit, - undefined - ); - }); - - it('should pass offset parameter', async () => { - const limit = 20; - const offset = 40; - - creditsService.getTransactionHistory.mockResolvedValue([]); - - await controller.getTransactionHistory(mockUser, limit, offset); - - expect(creditsService.getTransactionHistory).toHaveBeenCalledWith( - mockUser.userId, - limit, - offset - ); - }); - - it('should return empty array for user with no transactions', async () => { - creditsService.getTransactionHistory.mockResolvedValue([]); - - const result = await controller.getTransactionHistory(mockUser); - - expect(result).toEqual([]); - }); - }); - - // -------------------------------------------------------------------------- - // GET /credits/purchases - // -------------------------------------------------------------------------- - - describe('GET /credits/purchases', () => { - it('should return purchase history', async () => { - const packageId = 'pkg-123'; - const purchases = [ - mockPurchaseFactory.create(mockUser.userId, packageId, { - credits: 100, - priceEuroCents: 100, - }), - mockPurchaseFactory.create(mockUser.userId, packageId, { - credits: 500, - priceEuroCents: 450, - }), - ]; - - creditsService.getPurchaseHistory.mockResolvedValue(purchases as any); - - const result = await controller.getPurchaseHistory(mockUser); - - expect(result).toEqual(purchases); - expect(creditsService.getPurchaseHistory).toHaveBeenCalledWith(mockUser.userId); - }); - - it('should return empty array for user with no purchases', async () => { - creditsService.getPurchaseHistory.mockResolvedValue([]); - - const result = await controller.getPurchaseHistory(mockUser); - - expect(result).toEqual([]); - }); - }); - - // -------------------------------------------------------------------------- - // GET /credits/packages - // -------------------------------------------------------------------------- - - describe('GET /credits/packages', () => { - it('should return all available packages', async () => { - const packages = mockPackageFactory.createMany(3); - - creditsService.getPackages.mockResolvedValue(packages); - - const result = await controller.getPackages(); - - expect(result).toEqual(packages); - expect(creditsService.getPackages).toHaveBeenCalled(); - }); - - it('should return only active packages', async () => { - const activePackages = mockPackageFactory.createMany(2).map((pkg) => ({ - ...pkg, - active: true, - })); - - creditsService.getPackages.mockResolvedValue(activePackages); - - const result = await controller.getPackages(); - - expect(result.every((pkg: any) => pkg.active === true)).toBe(true); - }); - - it('should return empty array when no packages available', async () => { - creditsService.getPackages.mockResolvedValue([]); - - const result = await controller.getPackages(); - - expect(result).toEqual([]); - }); - }); - }); - - // B2B endpoints removed - functionality simplified to B2C only -}); diff --git a/services/mana-core-auth/src/credits/credits.controller.ts b/services/mana-core-auth/src/credits/credits.controller.ts deleted file mode 100644 index c48198f28..000000000 --- a/services/mana-core-auth/src/credits/credits.controller.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { Controller, Get, Post, Body, UseGuards, Query, ParseIntPipe, Param } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; -import { CreditsService } from './credits.service'; -import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; -import { CurrentUser } from '../common/decorators/current-user.decorator'; -import type { CurrentUserData } from '../common/decorators/current-user.decorator'; -import { UseCreditsDto } from './dto/use-credits.dto'; -import { PurchaseCreditsDto } from './dto/purchase-credits.dto'; -import { CreatePaymentLinkDto } from './dto/create-payment-link.dto'; - -@ApiTags('credits') -@ApiBearerAuth('JWT-auth') -@Controller('credits') -@UseGuards(JwtAuthGuard) -export class CreditsController { - constructor(private readonly creditsService: CreditsService) {} - - // ============================================================================ - // PERSONAL / B2C ENDPOINTS - // ============================================================================ - - @Get('balance') - @ApiOperation({ summary: 'Get current credit balance' }) - @ApiResponse({ status: 200, description: 'Returns user credit balance' }) - async getBalance(@CurrentUser() user: CurrentUserData) { - return this.creditsService.getBalance(user.userId); - } - - @Post('use') - @ApiOperation({ summary: 'Use credits (personal or guild pool)' }) - @ApiResponse({ status: 200, description: 'Credits used successfully' }) - async useCredits(@CurrentUser() user: CurrentUserData, @Body() useCreditsDto: UseCreditsDto) { - return this.creditsService.useCreditsWithSource(user.userId, useCreditsDto); - } - - @Get('transactions') - async getTransactionHistory( - @CurrentUser() user: CurrentUserData, - @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, - @Query('offset', new ParseIntPipe({ optional: true })) offset?: number - ) { - return this.creditsService.getTransactionHistory(user.userId, limit, offset); - } - - @Get('purchases') - async getPurchaseHistory(@CurrentUser() user: CurrentUserData) { - return this.creditsService.getPurchaseHistory(user.userId); - } - - @Get('packages') - @ApiOperation({ summary: 'Get available credit packages' }) - @ApiResponse({ status: 200, description: 'Returns list of active credit packages' }) - async getPackages() { - return this.creditsService.getPackages(); - } - - @Post('purchase') - @ApiOperation({ summary: 'Initiate credit purchase' }) - @ApiResponse({ - status: 201, - description: 'Returns Stripe PaymentIntent client secret for frontend payment', - }) - @ApiResponse({ status: 404, description: 'Package not found' }) - async initiatePurchase(@CurrentUser() user: CurrentUserData, @Body() dto: PurchaseCreditsDto) { - return this.creditsService.initiatePurchase(user.userId, dto.packageId); - } - - @Get('purchase/:purchaseId') - @ApiOperation({ summary: 'Get purchase status' }) - @ApiResponse({ status: 200, description: 'Returns purchase details and status' }) - @ApiResponse({ status: 404, description: 'Purchase not found' }) - async getPurchaseStatus( - @CurrentUser() user: CurrentUserData, - @Param('purchaseId') purchaseId: string - ) { - return this.creditsService.getPurchaseStatus(user.userId, purchaseId); - } - - @Post('payment-link') - @ApiOperation({ summary: 'Create payment link for credit purchase' }) - @ApiResponse({ - status: 201, - description: 'Returns Stripe Checkout URL for payment', - schema: { - properties: { - url: { type: 'string', description: 'Stripe Checkout URL' }, - purchaseId: { type: 'string', description: 'Purchase ID for tracking' }, - expiresAt: { type: 'string', format: 'date-time', description: 'Link expiration time' }, - package: { - type: 'object', - properties: { - name: { type: 'string' }, - credits: { type: 'number' }, - priceEuroCents: { type: 'number' }, - }, - }, - }, - }, - }) - @ApiResponse({ status: 404, description: 'Package not found' }) - async createPaymentLink(@CurrentUser() user: CurrentUserData, @Body() dto: CreatePaymentLinkDto) { - return this.creditsService.createPaymentLink(user.userId, dto.packageId, { - successUrl: dto.successUrl, - cancelUrl: dto.cancelUrl, - roomId: dto.roomId, - }); - } -} diff --git a/services/mana-core-auth/src/credits/credits.module.ts b/services/mana-core-auth/src/credits/credits.module.ts deleted file mode 100644 index 829f8f835..000000000 --- a/services/mana-core-auth/src/credits/credits.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { CreditsController } from './credits.controller'; -import { GuildCreditController } from './guild.controller'; -import { CreditsService } from './credits.service'; -import { GuildPoolService } from './guild-pool.service'; -import { StripeModule } from '../stripe/stripe.module'; - -@Module({ - imports: [forwardRef(() => StripeModule)], - controllers: [CreditsController, GuildCreditController], - providers: [CreditsService, GuildPoolService], - exports: [CreditsService, GuildPoolService], -}) -export class CreditsModule {} diff --git a/services/mana-core-auth/src/credits/credits.service.spec.ts b/services/mana-core-auth/src/credits/credits.service.spec.ts deleted file mode 100644 index 8d31c1b4d..000000000 --- a/services/mana-core-auth/src/credits/credits.service.spec.ts +++ /dev/null @@ -1,688 +0,0 @@ -/** - * CreditsService Unit Tests - * - * Tests all credit management flows: - * - Balance initialization - * - Credit usage with optimistic locking - * - Transaction history - * - Idempotency - * - * Simplified system - no free credits or B2B organization credits - */ - -import { Test } from '@nestjs/testing'; -import type { TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { BadRequestException, NotFoundException, ConflictException } from '@nestjs/common'; -import { CreditsService } from './credits.service'; -import { StripeService } from '../stripe/stripe.service'; -import { GuildPoolService } from './guild-pool.service'; -import { createMockConfigService } from '../__tests__/utils/test-helpers'; -import { - mockUserFactory, - mockBalanceFactory, - mockTransactionFactory, - mockPackageFactory, - mockPurchaseFactory, -} from '../__tests__/utils/mock-factories'; - -jest.mock('../db/connection'); - -describe('CreditsService', () => { - let service: CreditsService; - let configService: ConfigService; - let mockDb: any; - let queryResults: any[]; - let resultIndex: number; - - beforeEach(async () => { - // Track query results for thenable mock - queryResults = []; - resultIndex = 0; - - // Create thenable mock database - // Each query (SELECT, INSERT, UPDATE) will resolve to the next result in queryResults - mockDb = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - for: jest.fn().mockReturnThis(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - returning: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - offset: jest.fn().mockReturnThis(), - transaction: jest.fn(), - // Make the mock thenable - this allows await to work on the query chain - then: jest.fn((resolve) => resolve(queryResults[resultIndex++] || [])), - }; - - // Helper to set query results for the test - mockDb.mockResults = (...results: any[]) => { - queryResults = results; - resultIndex = 0; - }; - - const { getDb } = require('../db/connection'); - getDb.mockReturnValue(mockDb); - - const mockStripeService = { - getCustomerByUserId: jest.fn(), - createCheckoutSession: jest.fn(), - handleWebhook: jest.fn(), - }; - - const mockGuildPoolService = { - initializeGuildPool: jest.fn(), - getGuildPoolBalance: jest.fn(), - fundGuildPool: jest.fn(), - useGuildCredits: jest.fn(), - getGuildTransactions: jest.fn(), - setSpendingLimit: jest.fn(), - getSpendingLimits: jest.fn(), - getMemberSpendingSummary: jest.fn(), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - CreditsService, - { - provide: ConfigService, - useValue: createMockConfigService({}), - }, - { - provide: StripeService, - useValue: mockStripeService, - }, - { - provide: GuildPoolService, - useValue: mockGuildPoolService, - }, - ], - }).compile(); - - service = module.get(CreditsService); - configService = module.get(ConfigService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('initializeUserBalance', () => { - it('should create initial balance with zero credits', async () => { - const userId = 'user-123'; - - const mockBalance = mockBalanceFactory.create(userId, { - balance: 0, - totalEarned: 0, - totalSpent: 0, - }); - - // Mock query results in order: check existing, create balance - mockDb.mockResults( - [], // No existing balance - [mockBalance] // Create balance - ); - - const result = await service.initializeUserBalance(userId); - - expect(result).toEqual(mockBalance); - - // Verify balance was created with correct values (simplified - no free credits) - expect(mockDb.values).toHaveBeenCalledWith( - expect.objectContaining({ - userId, - balance: 0, - totalEarned: 0, - totalSpent: 0, - }) - ); - - // Verify only balance was created (no signup bonus transaction) - expect(mockDb.insert).toHaveBeenCalledTimes(1); - }); - - it('should not create duplicate balance if already exists', async () => { - const userId = 'user-123'; - - const existingBalance = mockBalanceFactory.create(userId); - - // Mock: Balance already exists - first query returns the existing balance - mockDb.mockResults([existingBalance]); - - const result = await service.initializeUserBalance(userId); - - expect(result).toEqual(existingBalance); - - // Verify no new balance was created - }); - }); - - describe('getBalance', () => { - it('should return user balance', async () => { - const userId = 'user-123'; - - const mockBalance = mockBalanceFactory.create(userId, { - balance: 1000, - totalEarned: 2000, - totalSpent: 1000, - }); - - mockDb.mockResults([mockBalance]); - - const result = await service.getBalance(userId); - - expect(result).toMatchObject({ - balance: 1000, - totalEarned: 2000, - totalSpent: 1000, - }); - }); - - it('should initialize balance if it does not exist', async () => { - const userId = 'user-new'; - - const newBalance = mockBalanceFactory.create(userId, { - balance: 0, - totalEarned: 0, - totalSpent: 0, - }); - - mockDb.mockResults( - [], // No balance found - [newBalance] // Created balance - ); - - const result = await service.getBalance(userId); - - expect(result).toMatchObject({ - balance: 0, - totalEarned: 0, - totalSpent: 0, - }); - }); - }); - - describe('useCredits', () => { - it('should successfully deduct credits from balance', async () => { - const userId = 'user-123'; - const useCreditsDto = { - amount: 10, - appId: 'memoro', - description: 'Audio transcription', - metadata: { fileId: 'file-123' }, - }; - - const mockBalance = mockBalanceFactory.create(userId, { - balance: 100, - totalSpent: 0, - version: 0, - }); - - // Mock transaction callback - mockDb.transaction.mockImplementation(async (callback: any) => { - const txMock: any = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - for: jest.fn().mockReturnThis(), - limit: jest.fn().mockResolvedValue([mockBalance]), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - returning: jest.fn().mockResolvedValue([ - { - ...mockBalance, - balance: 90, - totalSpent: 10, - version: 1, - }, - ]), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - }; - - txMock.returning.mockResolvedValue([ - mockTransactionFactory.create(userId, { - amount: -10, - balanceBefore: 100, - balanceAfter: 90, - }), - ]); - - return callback(txMock); - }); - - const result = await service.useCredits(userId, useCreditsDto); - - expect(result.success).toBe(true); - expect(result.transaction).toBeDefined(); - if ('newBalance' in result) { - expect(result.newBalance).toMatchObject({ - balance: 90, - totalSpent: 10, - }); - } - }); - - it('should throw BadRequestException if insufficient credits', async () => { - const userId = 'user-123'; - const useCreditsDto = { - amount: 200, - appId: 'picture', - description: 'Image generation', - }; - - const mockBalance = mockBalanceFactory.create(userId, { - balance: 50, - }); - - mockDb.transaction.mockImplementation(async (callback: any) => { - const txMock: any = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - for: jest.fn().mockReturnThis(), - limit: jest.fn().mockResolvedValue([mockBalance]), - }; - return callback(txMock); - }); - - await expect(service.useCredits(userId, useCreditsDto)).rejects.toThrow(BadRequestException); - await expect(service.useCredits(userId, useCreditsDto)).rejects.toThrow( - 'Insufficient credits' - ); - }); - - it('should throw NotFoundException if user balance not found', async () => { - const userId = 'non-existent-user'; - const useCreditsDto = { - amount: 10, - appId: 'chat', - description: 'Chat message', - }; - - mockDb.transaction.mockImplementation(async (callback: any) => { - const txMock: any = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - for: jest.fn().mockReturnThis(), - limit: jest.fn().mockResolvedValue([]), // No balance found - }; - return callback(txMock); - }); - - await expect(service.useCredits(userId, useCreditsDto)).rejects.toThrow(NotFoundException); - await expect(service.useCredits(userId, useCreditsDto)).rejects.toThrow( - 'User balance not found' - ); - }); - - it('should implement optimistic locking to prevent race conditions', async () => { - const userId = 'user-123'; - const useCreditsDto = { - amount: 10, - appId: 'memoro', - description: 'Audio processing', - }; - - const mockBalance = mockBalanceFactory.create(userId, { - balance: 100, - version: 5, - }); - - mockDb.transaction.mockImplementation(async (callback: any) => { - const txMock: any = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - for: jest.fn().mockReturnThis(), - limit: jest.fn().mockResolvedValue([mockBalance]), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - returning: jest.fn().mockResolvedValue([]), // Simulate version conflict - }; - return callback(txMock); - }); - - await expect(service.useCredits(userId, useCreditsDto)).rejects.toThrow(ConflictException); - await expect(service.useCredits(userId, useCreditsDto)).rejects.toThrow( - 'Balance was modified by another transaction' - ); - }); - - it('should support idempotency to prevent duplicate charges', async () => { - const userId = 'user-123'; - const useCreditsDto = { - amount: 10, - appId: 'picture', - description: 'Image generation', - idempotencyKey: 'unique-key-12345', - }; - - const existingTransaction = mockTransactionFactory.create(userId, { - idempotencyKey: 'unique-key-12345', - }); - - // Mock: Find existing transaction with same idempotency key - mockDb.mockResults([existingTransaction]); - - const result = await service.useCredits(userId, useCreditsDto); - - expect(result.success).toBe(true); - if ('message' in result) { - expect(result.message).toBe('Transaction already processed'); - } - expect(result.transaction).toEqual(existingTransaction); - - // Verify no actual deduction occurred - expect(mockDb.transaction).not.toHaveBeenCalled(); - }); - - it('should create transaction record with correct metadata', async () => { - const userId = 'user-123'; - const useCreditsDto = { - amount: 10, - appId: 'wisekeep', - description: 'Video analysis', - metadata: { - videoId: 'video-123', - duration: 120, - }, - idempotencyKey: 'idempotency-key-abc', - }; - - const mockBalance = mockBalanceFactory.create(userId, { - balance: 100, - version: 0, - }); - - const capturedValuesArray: any[] = []; - - mockDb.transaction.mockImplementation(async (callback: any) => { - const txMock: any = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - for: jest.fn().mockReturnThis(), - limit: jest.fn().mockResolvedValue([mockBalance]), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - returning: jest.fn().mockResolvedValue([mockBalance]), - insert: jest.fn().mockReturnThis(), - values: jest.fn((values: any) => { - capturedValuesArray.push(values); - return txMock; - }), - }; - - txMock.returning.mockResolvedValue([mockTransactionFactory.create(userId)]); - - return callback(txMock); - }); - - await service.useCredits(userId, useCreditsDto); - - // Find the transaction values (the one with type, amount, etc.) - const transactionValues = capturedValuesArray.find((v) => v.type !== undefined); - - expect(transactionValues).toMatchObject({ - userId, - type: 'usage', - status: 'completed', - amount: -10, - appId: 'wisekeep', - description: 'Video analysis', - metadata: { - videoId: 'video-123', - duration: 120, - }, - idempotencyKey: 'idempotency-key-abc', - }); - }); - - it('should track usage stats for analytics', async () => { - const userId = 'user-123'; - const useCreditsDto = { - amount: 25, - appId: 'chat', - description: 'Chat conversation', - }; - - const mockBalance = mockBalanceFactory.create(userId, { - balance: 100, - }); - - let capturedUsageStats: any; - - mockDb.transaction.mockImplementation(async (callback: any) => { - const txMock: any = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - for: jest.fn().mockReturnThis(), - limit: jest.fn().mockResolvedValue([mockBalance]), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - returning: jest.fn().mockResolvedValue([mockBalance]), - insert: jest.fn((table: any) => { - return txMock; - }), - values: jest.fn((values: any) => { - // Capture the second insert (usage stats) - if (values.creditsUsed !== undefined) { - capturedUsageStats = values; - } - return txMock; - }), - }; - - txMock.returning.mockResolvedValue([mockTransactionFactory.create(userId)]); - - return callback(txMock); - }); - - await service.useCredits(userId, useCreditsDto); - - expect(capturedUsageStats).toMatchObject({ - userId, - appId: 'chat', - creditsUsed: 25, - date: expect.any(Date), - }); - }); - }); - - describe('getTransactionHistory', () => { - it('should return paginated transaction history', async () => { - const userId = 'user-123'; - - const mockTransactions = mockTransactionFactory.createMany(userId, 3); - - mockDb.mockResults(mockTransactions); - - const result = await service.getTransactionHistory(userId, 50, 0); - - expect(result).toEqual(mockTransactions); - expect(mockDb.orderBy).toHaveBeenCalled(); - expect(mockDb.limit).toHaveBeenCalledWith(50); - expect(mockDb.offset).toHaveBeenCalledWith(0); - }); - - it('should support pagination with limit and offset', async () => { - const userId = 'user-123'; - - mockDb.mockResults([]); - - await service.getTransactionHistory(userId, 10, 20); - - expect(mockDb.limit).toHaveBeenCalledWith(10); - expect(mockDb.offset).toHaveBeenCalledWith(20); - }); - - it('should default to 50 items if limit not specified', async () => { - const userId = 'user-123'; - - mockDb.mockResults([]); - - await service.getTransactionHistory(userId); - - expect(mockDb.limit).toHaveBeenCalledWith(50); - expect(mockDb.offset).toHaveBeenCalledWith(0); - }); - - it('should order transactions by creation date descending', async () => { - const userId = 'user-123'; - - mockDb.mockResults([]); - - await service.getTransactionHistory(userId); - - // Verify orderBy was called (implementation checks for desc(transactions.createdAt)) - expect(mockDb.orderBy).toHaveBeenCalled(); - }); - }); - - describe('getPurchaseHistory', () => { - it('should return all purchases for user', async () => { - const userId = 'user-123'; - - const mockPurchases = [ - mockPurchaseFactory.create(userId, 'package-1'), - mockPurchaseFactory.create(userId, 'package-2'), - ]; - - mockDb.mockResults(mockPurchases); - - const result = await service.getPurchaseHistory(userId); - - expect(result).toEqual(mockPurchases); - expect(mockDb.where).toHaveBeenCalled(); - expect(mockDb.orderBy).toHaveBeenCalled(); - }); - - it('should order purchases by date descending', async () => { - const userId = 'user-123'; - - mockDb.mockResults([]); - - await service.getPurchaseHistory(userId); - - expect(mockDb.orderBy).toHaveBeenCalled(); - }); - }); - - describe('getPackages', () => { - it('should return only active packages', async () => { - const mockPackages = mockPackageFactory.createMany(3); - - mockDb.mockResults(mockPackages); - - const result = await service.getPackages(); - - expect(result).toEqual(mockPackages); - - // Verify only active packages were queried - expect(mockDb.where).toHaveBeenCalled(); - }); - - it('should order packages by sort order', async () => { - mockDb.mockResults([]); - - await service.getPackages(); - - expect(mockDb.orderBy).toHaveBeenCalled(); - }); - }); - - // Daily Credit Reset Logic tests removed - functionality simplified (no daily free credits) - - describe('Edge Cases', () => { - it('should handle zero credit usage', async () => { - const userId = 'user-123'; - const useCreditsDto = { - amount: 0, - appId: 'test', - description: 'Zero credit test', - }; - - const mockBalance = mockBalanceFactory.create(userId, { - balance: 100, - version: 0, - }); - - mockDb.transaction.mockImplementation(async (callback: any) => { - const txMock: any = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - for: jest.fn().mockReturnThis(), - limit: jest.fn().mockResolvedValue([mockBalance]), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - returning: jest.fn().mockResolvedValue([mockBalance]), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - }; - - txMock.returning.mockResolvedValue([mockTransactionFactory.create(userId, { amount: 0 })]); - - return callback(txMock); - }); - - const result = await service.useCredits(userId, useCreditsDto); - - expect(result.success).toBe(true); - }); - - it('should handle exact balance deduction', async () => { - const userId = 'user-123'; - const useCreditsDto = { - amount: 100, - appId: 'test', - description: 'Exact balance test', - }; - - const mockBalance = mockBalanceFactory.create(userId, { - balance: 100, - }); - - mockDb.transaction.mockImplementation(async (callback: any) => { - const txMock: any = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - for: jest.fn().mockReturnThis(), - limit: jest.fn().mockResolvedValue([mockBalance]), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - returning: jest.fn().mockResolvedValue([ - { - ...mockBalance, - balance: 0, - }, - ]), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - }; - - txMock.returning.mockResolvedValue([mockTransactionFactory.create(userId)]); - - return callback(txMock); - }); - - const result = await service.useCredits(userId, useCreditsDto); - - expect(result.success).toBe(true); - if ('newBalance' in result) { - expect(result.newBalance.balance).toBe(0); - } - }); - }); - - // B2B organization credit tests removed - functionality simplified to B2C only -}); diff --git a/services/mana-core-auth/src/credits/credits.service.ts b/services/mana-core-auth/src/credits/credits.service.ts deleted file mode 100644 index 0a628a20d..000000000 --- a/services/mana-core-auth/src/credits/credits.service.ts +++ /dev/null @@ -1,631 +0,0 @@ -import { - Injectable, - BadRequestException, - NotFoundException, - ConflictException, - Inject, - forwardRef, - Logger, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { eq, and, desc } from 'drizzle-orm'; -import { getDb } from '../db/connection'; -import { balances, transactions, purchases, packages, usageStats, users } from '../db/schema'; -import { UseCreditsDto } from './dto/use-credits.dto'; -import { StripeService } from '../stripe/stripe.service'; -import { GuildPoolService } from './guild-pool.service'; - -@Injectable() -export class CreditsService { - private readonly logger = new Logger(CreditsService.name); - - constructor( - private configService: ConfigService, - @Inject(forwardRef(() => StripeService)) - private stripeService: StripeService, - private guildPoolService: GuildPoolService - ) {} - - private getDb() { - const databaseUrl = this.configService.get('database.url'); - return getDb(databaseUrl!); - } - - async initializeUserBalance(userId: string) { - const db = this.getDb(); - - // Check if balance already exists - const [existingBalance] = await db - .select() - .from(balances) - .where(eq(balances.userId, userId)) - .limit(1); - - if (existingBalance) { - return existingBalance; - } - - // Create initial balance (starts at 0 - no signup bonus) - const [balance] = await db - .insert(balances) - .values({ - userId, - balance: 0, - totalEarned: 0, - totalSpent: 0, - }) - .returning(); - - return balance; - } - - async getBalance(userId: string) { - const db = this.getDb(); - - const [balance] = await db.select().from(balances).where(eq(balances.userId, userId)).limit(1); - - if (!balance) { - // Initialize balance if it doesn't exist - return this.initializeUserBalance(userId); - } - - return { - balance: balance.balance, - totalEarned: balance.totalEarned, - totalSpent: balance.totalSpent, - }; - } - - async useCredits(userId: string, useCreditsDto: UseCreditsDto) { - const db = this.getDb(); - - // Check for idempotency - if (useCreditsDto.idempotencyKey) { - const [existingTransaction] = await db - .select() - .from(transactions) - .where(eq(transactions.idempotencyKey, useCreditsDto.idempotencyKey)) - .limit(1); - - if (existingTransaction) { - return { - success: true, - transaction: existingTransaction, - message: 'Transaction already processed', - }; - } - } - - // Use a transaction for atomicity - return await db.transaction(async (tx) => { - // Get current balance with row lock (SELECT FOR UPDATE) - const [currentBalance] = await tx - .select() - .from(balances) - .where(eq(balances.userId, userId)) - .for('update') - .limit(1); - - if (!currentBalance) { - throw new NotFoundException('User balance not found'); - } - - if (currentBalance.balance < useCreditsDto.amount) { - throw new BadRequestException('Insufficient credits'); - } - - const newBalance = currentBalance.balance - useCreditsDto.amount; - const newTotalSpent = currentBalance.totalSpent + useCreditsDto.amount; - - // Update balance with optimistic locking - const updateResult = await tx - .update(balances) - .set({ - balance: newBalance, - totalSpent: newTotalSpent, - version: currentBalance.version + 1, - updatedAt: new Date(), - }) - .where(and(eq(balances.userId, userId), eq(balances.version, currentBalance.version))) - .returning(); - - if (updateResult.length === 0) { - throw new ConflictException('Balance was modified by another transaction. Please retry.'); - } - - // Create transaction record - const [transaction] = await tx - .insert(transactions) - .values({ - userId, - type: 'usage', - status: 'completed', - amount: -useCreditsDto.amount, - balanceBefore: currentBalance.balance, - balanceAfter: newBalance, - appId: useCreditsDto.appId, - description: useCreditsDto.description, - metadata: useCreditsDto.metadata, - idempotencyKey: useCreditsDto.idempotencyKey, - completedAt: new Date(), - }) - .returning(); - - // Track usage stats (for analytics) - const today = new Date(); - today.setHours(0, 0, 0, 0); - - await tx.insert(usageStats).values({ - userId, - appId: useCreditsDto.appId, - creditsUsed: useCreditsDto.amount, - date: today, - metadata: useCreditsDto.metadata, - }); - - return { - success: true, - transaction, - newBalance: { - balance: newBalance, - totalSpent: newTotalSpent, - }, - }; - }); - } - - /** - * Use credits with source routing. If creditSource is 'guild', routes to guild pool. - * Otherwise uses personal balance. Backward compatible — no creditSource = personal. - */ - async useCreditsWithSource(userId: string, dto: UseCreditsDto) { - if (dto.creditSource?.type === 'guild' && dto.creditSource.guildId) { - return this.guildPoolService.useGuildCredits(dto.creditSource.guildId, userId, dto); - } - return this.useCredits(userId, dto); - } - - async getTransactionHistory(userId: string, limit = 50, offset = 0) { - const db = this.getDb(); - - const transactionList = await db - .select() - .from(transactions) - .where(eq(transactions.userId, userId)) - .orderBy(desc(transactions.createdAt)) - .limit(limit) - .offset(offset); - - return transactionList; - } - - async getPurchaseHistory(userId: string) { - const db = this.getDb(); - - return await db - .select() - .from(purchases) - .where(eq(purchases.userId, userId)) - .orderBy(desc(purchases.createdAt)); - } - - async getPackages() { - const db = this.getDb(); - - return await db - .select() - .from(packages) - .where(eq(packages.active, true)) - .orderBy(packages.sortOrder); - } - - /** - * Create personal credit balance (B2C user) - * Alias for initializeUserBalance for clarity - */ - async createPersonalCreditBalance(userId: string) { - return this.initializeUserBalance(userId); - } - - // ============================================================================ - // STRIPE PURCHASE METHODS - // ============================================================================ - - /** - * Initiate a credit purchase - * Creates a pending purchase record and Stripe PaymentIntent - */ - async initiatePurchase( - userId: string, - packageId: string - ): Promise<{ - purchaseId: string; - clientSecret: string; - amount: number; - credits: number; - }> { - const db = this.getDb(); - - // 1. Get package details - const [pkg] = await db - .select() - .from(packages) - .where(and(eq(packages.id, packageId), eq(packages.active, true))) - .limit(1); - - if (!pkg) { - throw new NotFoundException('Package not found or inactive'); - } - - // 2. Get user email for Stripe customer - const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); - - if (!user) { - throw new NotFoundException('User not found'); - } - - // 3. Get or create Stripe customer - const stripeCustomerId = await this.stripeService.getOrCreateCustomer(userId, user.email); - - // 4. Create pending purchase record - const [purchase] = await db - .insert(purchases) - .values({ - userId, - packageId, - credits: pkg.credits, - priceEuroCents: pkg.priceEuroCents, - stripeCustomerId, - status: 'pending', - }) - .returning(); - - // 5. Create PaymentIntent - const paymentIntent = await this.stripeService.createPaymentIntent( - stripeCustomerId, - pkg.priceEuroCents, - { userId, packageId, purchaseId: purchase.id } - ); - - // 6. Update purchase with PaymentIntent ID - await db - .update(purchases) - .set({ stripePaymentIntentId: paymentIntent.id }) - .where(eq(purchases.id, purchase.id)); - - this.logger.log('Purchase initiated', { - purchaseId: purchase.id, - userId, - packageId, - credits: pkg.credits, - amount: pkg.priceEuroCents, - }); - - return { - purchaseId: purchase.id, - clientSecret: paymentIntent.client_secret!, - amount: pkg.priceEuroCents, - credits: pkg.credits, - }; - } - - /** - * Complete a purchase after successful payment - * Called from webhook handler - MUST be idempotent - */ - async completePurchase( - paymentIntentId: string - ): Promise<{ success: boolean; alreadyProcessed?: boolean; creditsAdded?: number }> { - const db = this.getDb(); - - return await db.transaction(async (tx) => { - // 1. Find purchase by PaymentIntent ID - const [purchase] = await tx - .select() - .from(purchases) - .where(eq(purchases.stripePaymentIntentId, paymentIntentId)) - .for('update') - .limit(1); - - if (!purchase) { - throw new NotFoundException('Purchase not found for PaymentIntent'); - } - - // 2. Idempotency check - already completed? - if (purchase.status === 'completed') { - return { success: true, alreadyProcessed: true }; - } - - // 3. Validate status transition - if (purchase.status !== 'pending') { - throw new BadRequestException(`Cannot complete purchase in status: ${purchase.status}`); - } - - // 4. Get or create user balance - let [balance] = await tx - .select() - .from(balances) - .where(eq(balances.userId, purchase.userId)) - .for('update') - .limit(1); - - if (!balance) { - // Initialize balance if not exists (starts at 0) - [balance] = await tx - .insert(balances) - .values({ - userId: purchase.userId, - balance: 0, - totalEarned: 0, - totalSpent: 0, - }) - .returning(); - } - - const newBalance = balance.balance + purchase.credits; - const now = new Date(); - - // 5. Update balance with optimistic locking - const updateResult = await tx - .update(balances) - .set({ - balance: newBalance, - totalEarned: balance.totalEarned + purchase.credits, - version: balance.version + 1, - updatedAt: now, - }) - .where(and(eq(balances.userId, purchase.userId), eq(balances.version, balance.version))) - .returning(); - - if (updateResult.length === 0) { - throw new ConflictException('Balance modified concurrently. Retry.'); - } - - // 6. Update purchase status - await tx - .update(purchases) - .set({ - status: 'completed', - completedAt: now, - }) - .where(eq(purchases.id, purchase.id)); - - // 7. Create transaction ledger entry - await tx.insert(transactions).values({ - userId: purchase.userId, - type: 'purchase', - status: 'completed', - amount: purchase.credits, - balanceBefore: balance.balance, - balanceAfter: newBalance, - appId: 'stripe', - description: `Credit purchase: ${purchase.credits} credits`, - idempotencyKey: `purchase:${paymentIntentId}`, - completedAt: now, - metadata: { - purchaseId: purchase.id, - packageId: purchase.packageId, - stripePaymentIntentId: paymentIntentId, - priceEuroCents: purchase.priceEuroCents, - }, - }); - - this.logger.log('Purchase completed', { - purchaseId: purchase.id, - userId: purchase.userId, - creditsAdded: purchase.credits, - newBalance, - }); - - return { success: true, alreadyProcessed: false, creditsAdded: purchase.credits }; - }); - } - - /** - * Mark a purchase as failed - * Called from webhook handler when payment fails - */ - async failPurchase(paymentIntentId: string, failureReason: string): Promise { - const db = this.getDb(); - - const [purchase] = await db - .select() - .from(purchases) - .where(eq(purchases.stripePaymentIntentId, paymentIntentId)) - .limit(1); - - if (!purchase) { - this.logger.warn('Purchase not found for failed PaymentIntent', { paymentIntentId }); - return; - } - - // Only update if still pending - if (purchase.status !== 'pending') { - this.logger.debug('Purchase already processed, skipping failure update', { - purchaseId: purchase.id, - currentStatus: purchase.status, - }); - return; - } - - await db - .update(purchases) - .set({ - status: 'failed', - metadata: { - ...((purchase.metadata as Record) || {}), - failureReason, - failedAt: new Date().toISOString(), - }, - }) - .where(eq(purchases.id, purchase.id)); - - this.logger.log('Purchase marked as failed', { - purchaseId: purchase.id, - paymentIntentId, - failureReason, - }); - } - - /** - * Get purchase status by ID - */ - async getPurchaseStatus(userId: string, purchaseId: string) { - const db = this.getDb(); - - const [purchase] = await db - .select() - .from(purchases) - .where(and(eq(purchases.id, purchaseId), eq(purchases.userId, userId))) - .limit(1); - - if (!purchase) { - throw new NotFoundException('Purchase not found'); - } - - return { - id: purchase.id, - status: purchase.status, - credits: purchase.credits, - priceEuroCents: purchase.priceEuroCents, - createdAt: purchase.createdAt, - completedAt: purchase.completedAt, - }; - } - - /** - * Update purchase with PaymentIntent ID - * Called from webhook when checkout.session.completed fires - */ - async updatePurchasePaymentIntent(purchaseId: string, paymentIntentId: string): Promise { - const db = this.getDb(); - - await db - .update(purchases) - .set({ - stripePaymentIntentId: paymentIntentId, - }) - .where(eq(purchases.id, purchaseId)); - } - - // ============================================================================ - // PAYMENT LINK METHODS (for Matrix Bots) - // ============================================================================ - - /** - * Create a Stripe Checkout Session URL for credit purchase - * Used by Matrix bots to allow users to buy credits without leaving chat - */ - async createPaymentLink( - userId: string, - packageId: string, - options?: { - successUrl?: string; - cancelUrl?: string; - roomId?: string; - } - ): Promise<{ - url: string; - purchaseId: string; - expiresAt: Date; - package: { - name: string; - credits: number; - priceEuroCents: number; - }; - }> { - const db = this.getDb(); - - // 1. Get package details - const [pkg] = await db - .select() - .from(packages) - .where(and(eq(packages.id, packageId), eq(packages.active, true))) - .limit(1); - - if (!pkg) { - throw new NotFoundException('Package not found or inactive'); - } - - // 2. Get user email for Stripe customer - const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); - - if (!user) { - throw new NotFoundException('User not found'); - } - - // 3. Get or create Stripe customer - const stripeCustomerId = await this.stripeService.getOrCreateCustomer(userId, user.email); - - // 4. Create pending purchase record - const [purchase] = await db - .insert(purchases) - .values({ - userId, - packageId, - credits: pkg.credits, - priceEuroCents: pkg.priceEuroCents, - stripeCustomerId, - status: 'pending', - metadata: options?.roomId ? { roomId: options.roomId } : undefined, - }) - .returning(); - - // 5. Build URLs - const baseUrl = this.configService.get('app.baseUrl') || 'https://mana.how'; - const successUrl = - options?.successUrl || `${baseUrl}/credits/success?purchase_id=${purchase.id}`; - const cancelUrl = options?.cancelUrl || `${baseUrl}/credits/cancelled`; - - // 6. Create Checkout Session - const session = await this.stripeService.createCheckoutSession({ - customerId: stripeCustomerId, - amountCents: pkg.priceEuroCents, - productName: pkg.name, - credits: pkg.credits, - metadata: { - userId, - packageId, - purchaseId: purchase.id, - roomId: options?.roomId, - }, - successUrl, - cancelUrl, - }); - - // 7. Update purchase with session ID - await db - .update(purchases) - .set({ - stripePaymentIntentId: session.payment_intent as string, - metadata: { - ...((purchase.metadata as Record) || {}), - stripeSessionId: session.id, - }, - }) - .where(eq(purchases.id, purchase.id)); - - // Session expires in 24 hours - const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); - - this.logger.log('Payment link created', { - purchaseId: purchase.id, - userId, - packageId, - packageName: pkg.name, - credits: pkg.credits, - sessionId: session.id, - }); - - return { - url: session.url!, - purchaseId: purchase.id, - expiresAt, - package: { - name: pkg.name, - credits: pkg.credits, - priceEuroCents: pkg.priceEuroCents, - }, - }; - } -} diff --git a/services/mana-core-auth/src/credits/dto/create-payment-link.dto.ts b/services/mana-core-auth/src/credits/dto/create-payment-link.dto.ts deleted file mode 100644 index 64d5284ab..000000000 --- a/services/mana-core-auth/src/credits/dto/create-payment-link.dto.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { IsUUID, IsOptional, IsUrl, IsString } from 'class-validator'; - -export class CreatePaymentLinkDto { - @IsUUID() - packageId: string; - - @IsOptional() - @IsUrl() - successUrl?: string; - - @IsOptional() - @IsUrl() - cancelUrl?: string; - - @IsOptional() - @IsString() - roomId?: string; // For Matrix bot notification after payment -} diff --git a/services/mana-core-auth/src/credits/dto/fund-guild-pool.dto.ts b/services/mana-core-auth/src/credits/dto/fund-guild-pool.dto.ts deleted file mode 100644 index 6ca9e578b..000000000 --- a/services/mana-core-auth/src/credits/dto/fund-guild-pool.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IsInt, IsPositive, IsOptional, IsString } from 'class-validator'; - -export class FundGuildPoolDto { - @IsInt() - @IsPositive() - amount: number; - - @IsString() - @IsOptional() - idempotencyKey?: string; -} diff --git a/services/mana-core-auth/src/credits/dto/purchase-credits.dto.ts b/services/mana-core-auth/src/credits/dto/purchase-credits.dto.ts deleted file mode 100644 index 441caceda..000000000 --- a/services/mana-core-auth/src/credits/dto/purchase-credits.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { IsUUID, IsOptional } from 'class-validator'; - -export class PurchaseCreditsDto { - @IsUUID() - packageId: string; - - @IsOptional() - metadata?: Record; -} diff --git a/services/mana-core-auth/src/credits/dto/set-spending-limit.dto.ts b/services/mana-core-auth/src/credits/dto/set-spending-limit.dto.ts deleted file mode 100644 index e35ebf7b1..000000000 --- a/services/mana-core-auth/src/credits/dto/set-spending-limit.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { IsInt, IsOptional, Min } from 'class-validator'; - -export class SetSpendingLimitDto { - @IsOptional() - @IsInt() - @Min(0) - dailyLimit?: number | null; - - @IsOptional() - @IsInt() - @Min(0) - monthlyLimit?: number | null; -} diff --git a/services/mana-core-auth/src/credits/dto/use-credits.dto.ts b/services/mana-core-auth/src/credits/dto/use-credits.dto.ts deleted file mode 100644 index e4f8958a4..000000000 --- a/services/mana-core-auth/src/credits/dto/use-credits.dto.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - IsString, - IsInt, - IsPositive, - IsOptional, - IsObject, - IsIn, - ValidateNested, - ValidateIf, -} from 'class-validator'; -import { Type } from 'class-transformer'; - -export class CreditSourceDto { - @IsIn(['personal', 'guild']) - type: 'personal' | 'guild'; - - @ValidateIf((o) => o.type === 'guild') - @IsString() - guildId?: string; -} - -export class UseCreditsDto { - @IsInt() - @IsPositive() - amount: number; - - @IsString() - appId: string; - - @IsString() - description: string; - - @IsString() - @IsOptional() - idempotencyKey?: string; - - @IsObject() - @IsOptional() - metadata?: Record; - - @IsOptional() - @ValidateNested() - @Type(() => CreditSourceDto) - creditSource?: CreditSourceDto; -} diff --git a/services/mana-core-auth/src/credits/guild-pool.service.ts b/services/mana-core-auth/src/credits/guild-pool.service.ts deleted file mode 100644 index eb55fedd9..000000000 --- a/services/mana-core-auth/src/credits/guild-pool.service.ts +++ /dev/null @@ -1,581 +0,0 @@ -import { - Injectable, - BadRequestException, - ForbiddenException, - NotFoundException, - ConflictException, - Logger, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { eq, and, desc, gte, sql } from 'drizzle-orm'; -import { getDb } from '../db/connection'; -import { - balances, - transactions, - guildPools, - guildTransactions, - guildSpendingLimits, - members, - usageStats, -} from '../db/schema'; -import { UseCreditsDto } from './dto/use-credits.dto'; - -@Injectable() -export class GuildPoolService { - private readonly logger = new Logger(GuildPoolService.name); - - constructor(private configService: ConfigService) {} - - private getDb() { - const databaseUrl = this.configService.get('database.url'); - return getDb(databaseUrl!); - } - - /** - * Verify user is a member of the guild. Returns the member record. - */ - private async verifyMembership(db: ReturnType, guildId: string, userId: string) { - const [member] = await db - .select() - .from(members) - .where(and(eq(members.organizationId, guildId), eq(members.userId, userId))) - .limit(1); - - if (!member) { - throw new ForbiddenException('User is not a member of this guild'); - } - - return member; - } - - /** - * Verify user is owner or admin of the guild. - */ - private async verifyOwnerOrAdmin(db: ReturnType, guildId: string, userId: string) { - const member = await this.verifyMembership(db, guildId, userId); - - if (member.role !== 'owner' && member.role !== 'admin') { - throw new ForbiddenException('Only guild owners and admins can perform this action'); - } - - return member; - } - - /** - * Initialize a guild pool with balance 0. Called when a guild is created. - */ - async initializeGuildPool(organizationId: string) { - const db = this.getDb(); - - const [existing] = await db - .select() - .from(guildPools) - .where(eq(guildPools.organizationId, organizationId)) - .limit(1); - - if (existing) { - return existing; - } - - const [pool] = await db.insert(guildPools).values({ organizationId }).returning(); - - this.logger.log('Guild pool initialized', { organizationId }); - return pool; - } - - /** - * Get guild pool balance. Verifies the requesting user is a guild member. - */ - async getGuildPoolBalance(guildId: string, userId: string) { - const db = this.getDb(); - await this.verifyMembership(db, guildId, userId); - - const [pool] = await db - .select() - .from(guildPools) - .where(eq(guildPools.organizationId, guildId)) - .limit(1); - - if (!pool) { - throw new NotFoundException('Guild pool not found'); - } - - return { - balance: pool.balance, - totalFunded: pool.totalFunded, - totalSpent: pool.totalSpent, - }; - } - - /** - * Fund the guild pool from a user's personal balance. - * Only owners and admins can fund. - */ - async fundGuildPool(guildId: string, funderId: string, amount: number, idempotencyKey?: string) { - if (amount <= 0) { - throw new BadRequestException('Amount must be positive'); - } - - const db = this.getDb(); - await this.verifyOwnerOrAdmin(db, guildId, funderId); - - // Check idempotency - if (idempotencyKey) { - const [existing] = await db - .select() - .from(guildTransactions) - .where(eq(guildTransactions.idempotencyKey, idempotencyKey)) - .limit(1); - - if (existing) { - return { success: true, message: 'Transaction already processed' }; - } - } - - return await db.transaction(async (tx) => { - // Lock and check personal balance - const [personalBalance] = await tx - .select() - .from(balances) - .where(eq(balances.userId, funderId)) - .for('update') - .limit(1); - - if (!personalBalance) { - throw new NotFoundException('Personal balance not found'); - } - - if (personalBalance.balance < amount) { - throw new BadRequestException('Insufficient personal credits'); - } - - // Lock and update guild pool - const [pool] = await tx - .select() - .from(guildPools) - .where(eq(guildPools.organizationId, guildId)) - .for('update') - .limit(1); - - if (!pool) { - throw new NotFoundException('Guild pool not found'); - } - - const newPersonalBalance = personalBalance.balance - amount; - const newPoolBalance = pool.balance + amount; - - // Debit personal balance - const personalUpdate = await tx - .update(balances) - .set({ - balance: newPersonalBalance, - totalSpent: personalBalance.totalSpent + amount, - version: personalBalance.version + 1, - updatedAt: new Date(), - }) - .where(and(eq(balances.userId, funderId), eq(balances.version, personalBalance.version))) - .returning(); - - if (personalUpdate.length === 0) { - throw new ConflictException('Personal balance was modified concurrently. Please retry.'); - } - - // Credit guild pool - const poolUpdate = await tx - .update(guildPools) - .set({ - balance: newPoolBalance, - totalFunded: pool.totalFunded + amount, - version: pool.version + 1, - updatedAt: new Date(), - }) - .where(and(eq(guildPools.organizationId, guildId), eq(guildPools.version, pool.version))) - .returning(); - - if (poolUpdate.length === 0) { - throw new ConflictException('Guild pool was modified concurrently. Please retry.'); - } - - // Record personal transaction (debit) - await tx.insert(transactions).values({ - userId: funderId, - type: 'guild_funding', - status: 'completed', - amount: -amount, - balanceBefore: personalBalance.balance, - balanceAfter: newPersonalBalance, - appId: 'guild', - description: `Funded guild pool`, - guildId, - idempotencyKey: idempotencyKey ? `personal:${idempotencyKey}` : undefined, - completedAt: new Date(), - }); - - // Record guild transaction (credit) - await tx.insert(guildTransactions).values({ - organizationId: guildId, - userId: funderId, - type: 'funding', - amount, - balanceBefore: pool.balance, - balanceAfter: newPoolBalance, - description: `Pool funded by member`, - idempotencyKey, - completedAt: new Date(), - }); - - this.logger.log('Guild pool funded', { guildId, funderId, amount, newPoolBalance }); - - return { - success: true, - personalBalance: { balance: newPersonalBalance }, - poolBalance: { balance: newPoolBalance, totalFunded: pool.totalFunded + amount }, - }; - }); - } - - /** - * Use credits from the guild pool. Any member can use, subject to spending limits. - */ - async useGuildCredits(guildId: string, userId: string, dto: UseCreditsDto) { - const db = this.getDb(); - await this.verifyMembership(db, guildId, userId); - - // Check idempotency - if (dto.idempotencyKey) { - const [existing] = await db - .select() - .from(guildTransactions) - .where(eq(guildTransactions.idempotencyKey, dto.idempotencyKey)) - .limit(1); - - if (existing) { - return { success: true, message: 'Transaction already processed' }; - } - } - - // Check spending limits before entering transaction - await this.checkSpendingLimits(db, guildId, userId, dto.amount); - - return await db.transaction(async (tx) => { - // Lock guild pool - const [pool] = await tx - .select() - .from(guildPools) - .where(eq(guildPools.organizationId, guildId)) - .for('update') - .limit(1); - - if (!pool) { - throw new NotFoundException('Guild pool not found'); - } - - if (pool.balance < dto.amount) { - throw new BadRequestException('Insufficient guild pool credits'); - } - - const newBalance = pool.balance - dto.amount; - - // Update pool - const poolUpdate = await tx - .update(guildPools) - .set({ - balance: newBalance, - totalSpent: pool.totalSpent + dto.amount, - version: pool.version + 1, - updatedAt: new Date(), - }) - .where(and(eq(guildPools.organizationId, guildId), eq(guildPools.version, pool.version))) - .returning(); - - if (poolUpdate.length === 0) { - throw new ConflictException('Guild pool was modified concurrently. Please retry.'); - } - - // Record guild transaction - const [transaction] = await tx - .insert(guildTransactions) - .values({ - organizationId: guildId, - userId, - type: 'usage', - amount: -dto.amount, - balanceBefore: pool.balance, - balanceAfter: newBalance, - appId: dto.appId, - description: dto.description, - metadata: dto.metadata, - idempotencyKey: dto.idempotencyKey, - completedAt: new Date(), - }) - .returning(); - - // Track usage stats - const today = new Date(); - today.setHours(0, 0, 0, 0); - - await tx.insert(usageStats).values({ - userId, - appId: dto.appId, - creditsUsed: dto.amount, - date: today, - metadata: { ...dto.metadata, guildId }, - }); - - this.logger.log('Guild credits used', { - guildId, - userId, - amount: dto.amount, - appId: dto.appId, - newBalance, - }); - - return { - success: true, - transaction, - newBalance: { balance: newBalance }, - }; - }); - } - - /** - * Check if the user's spending is within their limits for this guild. - */ - private async checkSpendingLimits( - db: ReturnType, - guildId: string, - userId: string, - amount: number - ) { - const [limits] = await db - .select() - .from(guildSpendingLimits) - .where( - and(eq(guildSpendingLimits.organizationId, guildId), eq(guildSpendingLimits.userId, userId)) - ) - .limit(1); - - // No limits set = unlimited - if (!limits) return; - - const now = new Date(); - - if (limits.dailyLimit !== null) { - const startOfDay = new Date(now); - startOfDay.setHours(0, 0, 0, 0); - - const [dailySpending] = await db - .select({ - total: sql`COALESCE(SUM(ABS(${guildTransactions.amount})), 0)`, - }) - .from(guildTransactions) - .where( - and( - eq(guildTransactions.organizationId, guildId), - eq(guildTransactions.userId, userId), - eq(guildTransactions.type, 'usage'), - gte(guildTransactions.createdAt, startOfDay) - ) - ); - - const spent = Number(dailySpending.total); - if (spent + amount > limits.dailyLimit) { - throw new BadRequestException( - `Daily spending limit exceeded. Limit: ${limits.dailyLimit}, spent today: ${spent}, requested: ${amount}` - ); - } - } - - if (limits.monthlyLimit !== null) { - const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); - - const [monthlySpending] = await db - .select({ - total: sql`COALESCE(SUM(ABS(${guildTransactions.amount})), 0)`, - }) - .from(guildTransactions) - .where( - and( - eq(guildTransactions.organizationId, guildId), - eq(guildTransactions.userId, userId), - eq(guildTransactions.type, 'usage'), - gte(guildTransactions.createdAt, startOfMonth) - ) - ); - - const spent = Number(monthlySpending.total); - if (spent + amount > limits.monthlyLimit) { - throw new BadRequestException( - `Monthly spending limit exceeded. Limit: ${limits.monthlyLimit}, spent this month: ${spent}, requested: ${amount}` - ); - } - } - } - - /** - * Get guild transaction history. Owners/admins see all; members see only their own. - */ - async getGuildTransactions(guildId: string, userId: string, limit = 50, offset = 0) { - const db = this.getDb(); - const member = await this.verifyMembership(db, guildId, userId); - - const isAdmin = member.role === 'owner' || member.role === 'admin'; - - const conditions = [eq(guildTransactions.organizationId, guildId)]; - - // Members can only see their own transactions - if (!isAdmin) { - conditions.push(eq(guildTransactions.userId, userId)); - } - - return await db - .select() - .from(guildTransactions) - .where(and(...conditions)) - .orderBy(desc(guildTransactions.createdAt)) - .limit(limit) - .offset(offset); - } - - /** - * Set spending limits for a guild member. Only owner/admin can set limits. - */ - async setSpendingLimit( - guildId: string, - setterId: string, - targetUserId: string, - dailyLimit?: number | null, - monthlyLimit?: number | null - ) { - const db = this.getDb(); - await this.verifyOwnerOrAdmin(db, guildId, setterId); - await this.verifyMembership(db, guildId, targetUserId); - - if (dailyLimit !== undefined && dailyLimit !== null && dailyLimit < 0) { - throw new BadRequestException('Daily limit must be non-negative'); - } - if (monthlyLimit !== undefined && monthlyLimit !== null && monthlyLimit < 0) { - throw new BadRequestException('Monthly limit must be non-negative'); - } - - // Upsert spending limits - const [existing] = await db - .select() - .from(guildSpendingLimits) - .where( - and( - eq(guildSpendingLimits.organizationId, guildId), - eq(guildSpendingLimits.userId, targetUserId) - ) - ) - .limit(1); - - if (existing) { - const [updated] = await db - .update(guildSpendingLimits) - .set({ - dailyLimit: dailyLimit === undefined ? existing.dailyLimit : dailyLimit, - monthlyLimit: monthlyLimit === undefined ? existing.monthlyLimit : monthlyLimit, - updatedAt: new Date(), - }) - .where(eq(guildSpendingLimits.id, existing.id)) - .returning(); - - return updated; - } - - const [created] = await db - .insert(guildSpendingLimits) - .values({ - organizationId: guildId, - userId: targetUserId, - dailyLimit: dailyLimit ?? null, - monthlyLimit: monthlyLimit ?? null, - }) - .returning(); - - return created; - } - - /** - * Get spending limits for a guild member. - */ - async getSpendingLimits(guildId: string, userId: string) { - const db = this.getDb(); - await this.verifyMembership(db, guildId, userId); - - const [limits] = await db - .select() - .from(guildSpendingLimits) - .where( - and(eq(guildSpendingLimits.organizationId, guildId), eq(guildSpendingLimits.userId, userId)) - ) - .limit(1); - - return limits || { dailyLimit: null, monthlyLimit: null }; - } - - /** - * Get a member's spending summary (today + this month) vs their limits. - */ - async getMemberSpendingSummary(guildId: string, userId: string) { - const db = this.getDb(); - await this.verifyMembership(db, guildId, userId); - - const now = new Date(); - const startOfDay = new Date(now); - startOfDay.setHours(0, 0, 0, 0); - const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); - - const [dailySpending] = await db - .select({ - total: sql`COALESCE(SUM(ABS(${guildTransactions.amount})), 0)`, - }) - .from(guildTransactions) - .where( - and( - eq(guildTransactions.organizationId, guildId), - eq(guildTransactions.userId, userId), - eq(guildTransactions.type, 'usage'), - gte(guildTransactions.createdAt, startOfDay) - ) - ); - - const [monthlySpending] = await db - .select({ - total: sql`COALESCE(SUM(ABS(${guildTransactions.amount})), 0)`, - }) - .from(guildTransactions) - .where( - and( - eq(guildTransactions.organizationId, guildId), - eq(guildTransactions.userId, userId), - eq(guildTransactions.type, 'usage'), - gte(guildTransactions.createdAt, startOfMonth) - ) - ); - - const [limits] = await db - .select() - .from(guildSpendingLimits) - .where( - and(eq(guildSpendingLimits.organizationId, guildId), eq(guildSpendingLimits.userId, userId)) - ) - .limit(1); - - return { - spentToday: Number(dailySpending.total), - spentThisMonth: Number(monthlySpending.total), - dailyLimit: limits?.dailyLimit ?? null, - monthlyLimit: limits?.monthlyLimit ?? null, - dailyRemaining: - limits?.dailyLimit !== null && limits?.dailyLimit !== undefined - ? Math.max(0, limits.dailyLimit - Number(dailySpending.total)) - : null, - monthlyRemaining: - limits?.monthlyLimit !== null && limits?.monthlyLimit !== undefined - ? Math.max(0, limits.monthlyLimit - Number(monthlySpending.total)) - : null, - }; - } -} diff --git a/services/mana-core-auth/src/credits/guild.controller.ts b/services/mana-core-auth/src/credits/guild.controller.ts deleted file mode 100644 index 3cb543793..000000000 --- a/services/mana-core-auth/src/credits/guild.controller.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { - Controller, - Get, - Post, - Put, - Body, - Param, - Query, - ParseIntPipe, - UseGuards, -} from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; -import { GuildPoolService } from './guild-pool.service'; -import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; -import { CurrentUser } from '../common/decorators/current-user.decorator'; -import type { CurrentUserData } from '../common/decorators/current-user.decorator'; -import { UseCreditsDto } from './dto/use-credits.dto'; -import { FundGuildPoolDto } from './dto/fund-guild-pool.dto'; -import { SetSpendingLimitDto } from './dto/set-spending-limit.dto'; - -@ApiTags('credits/guild') -@ApiBearerAuth('JWT-auth') -@Controller('credits/guild') -@UseGuards(JwtAuthGuard) -export class GuildCreditController { - constructor(private readonly guildPoolService: GuildPoolService) {} - - @Get(':guildId/balance') - @ApiOperation({ summary: 'Get guild pool balance' }) - @ApiResponse({ status: 200, description: 'Returns guild pool balance' }) - @ApiResponse({ status: 403, description: 'Not a member of this guild' }) - async getBalance(@Param('guildId') guildId: string, @CurrentUser() user: CurrentUserData) { - return this.guildPoolService.getGuildPoolBalance(guildId, user.userId); - } - - @Post(':guildId/fund') - @ApiOperation({ summary: 'Fund guild pool from personal balance' }) - @ApiResponse({ status: 200, description: 'Pool funded successfully' }) - @ApiResponse({ status: 400, description: 'Insufficient personal credits' }) - @ApiResponse({ status: 403, description: 'Only owners and admins can fund' }) - async fundPool( - @Param('guildId') guildId: string, - @CurrentUser() user: CurrentUserData, - @Body() dto: FundGuildPoolDto - ) { - return this.guildPoolService.fundGuildPool( - guildId, - user.userId, - dto.amount, - dto.idempotencyKey - ); - } - - @Post(':guildId/use') - @ApiOperation({ summary: 'Use credits from guild pool' }) - @ApiResponse({ status: 200, description: 'Credits used successfully' }) - @ApiResponse({ status: 400, description: 'Insufficient credits or spending limit exceeded' }) - @ApiResponse({ status: 403, description: 'Not a member of this guild' }) - async useCredits( - @Param('guildId') guildId: string, - @CurrentUser() user: CurrentUserData, - @Body() dto: UseCreditsDto - ) { - return this.guildPoolService.useGuildCredits(guildId, user.userId, dto); - } - - @Get(':guildId/transactions') - @ApiOperation({ summary: 'Get guild transaction history' }) - @ApiResponse({ status: 200, description: 'Returns guild transactions' }) - async getTransactions( - @Param('guildId') guildId: string, - @CurrentUser() user: CurrentUserData, - @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, - @Query('offset', new ParseIntPipe({ optional: true })) offset?: number - ) { - return this.guildPoolService.getGuildTransactions(guildId, user.userId, limit, offset); - } - - @Get(':guildId/members/:userId/spending') - @ApiOperation({ summary: 'Get member spending summary' }) - @ApiResponse({ status: 200, description: 'Returns spending summary with limits' }) - async getMemberSpending( - @Param('guildId') guildId: string, - @Param('userId') targetUserId: string, - @CurrentUser() user: CurrentUserData - ) { - // Members can view their own spending, owners/admins can view any member - const effectiveUserId = targetUserId === 'me' ? user.userId : targetUserId; - return this.guildPoolService.getMemberSpendingSummary(guildId, effectiveUserId); - } - - @Get(':guildId/members/:userId/limits') - @ApiOperation({ summary: 'Get member spending limits' }) - @ApiResponse({ status: 200, description: 'Returns spending limits' }) - async getSpendingLimits( - @Param('guildId') guildId: string, - @Param('userId') targetUserId: string, - @CurrentUser() user: CurrentUserData - ) { - const effectiveUserId = targetUserId === 'me' ? user.userId : targetUserId; - return this.guildPoolService.getSpendingLimits(guildId, effectiveUserId); - } - - @Put(':guildId/members/:userId/limits') - @ApiOperation({ summary: 'Set member spending limits' }) - @ApiResponse({ status: 200, description: 'Spending limits updated' }) - @ApiResponse({ status: 403, description: 'Only owners and admins can set limits' }) - async setSpendingLimits( - @Param('guildId') guildId: string, - @Param('userId') targetUserId: string, - @CurrentUser() user: CurrentUserData, - @Body() dto: SetSpendingLimitDto - ) { - return this.guildPoolService.setSpendingLimit( - guildId, - user.userId, - targetUserId, - dto.dailyLimit, - dto.monthlyLimit - ); - } -} diff --git a/services/mana-core-auth/src/db/schema/credits.schema.ts b/services/mana-core-auth/src/db/schema/credits.schema.ts deleted file mode 100644 index a8fd3e4c8..000000000 --- a/services/mana-core-auth/src/db/schema/credits.schema.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { - pgSchema, - uuid, - integer, - text, - timestamp, - jsonb, - index, - pgEnum, - boolean, -} from 'drizzle-orm/pg-core'; -import { users } from './auth.schema'; - -export const creditsSchema = pgSchema('credits'); - -// Transaction types enum -// Simplified: removed bonus, expiry, adjustment - kept core types -export const transactionTypeEnum = pgEnum('transaction_type', [ - 'purchase', - 'usage', - 'refund', - 'gift', - 'guild_funding', -]); - -// Transaction status enum -export const transactionStatusEnum = pgEnum('transaction_status', [ - 'pending', - 'completed', - 'failed', - 'cancelled', -]); - -// Stripe customer mapping (for reusing Stripe customers across purchases) -export const stripeCustomers = creditsSchema.table('stripe_customers', { - userId: text('user_id') - .primaryKey() - .references(() => users.id, { onDelete: 'cascade' }), - 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) -// Simplified: removed free credits columns (no signup bonus, no daily credits) -export const balances = creditsSchema.table('balances', { - userId: text('user_id') - .primaryKey() - .references(() => users.id, { onDelete: 'cascade' }), - 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(), -}); - -// Transaction ledger -export const transactions = creditsSchema.table( - 'transactions', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .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'), // Set when transaction is guild-related (e.g. funding a guild pool) - 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') - .references(() => users.id, { onDelete: 'cascade' }) - .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 (for analytics) -export const usageStats = creditsSchema.table( - 'usage_stats', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .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), - }) -); - -// Guild pool tables are in guilds.schema.ts diff --git a/services/mana-core-auth/src/db/schema/gifts.schema.ts b/services/mana-core-auth/src/db/schema/gifts.schema.ts deleted file mode 100644 index 3aa1b1fe2..000000000 --- a/services/mana-core-auth/src/db/schema/gifts.schema.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Gifts Schema - * - * Database schema for user-generated gift codes including: - * - Gift codes (simple, personalized, split, first_come, riddle) - * - Gift redemptions tracking - * - Credit reservations and releases - */ - -import { - pgSchema, - uuid, - text, - timestamp, - integer, - index, - pgEnum, -} from 'drizzle-orm/pg-core'; -import { users } from './auth.schema'; - -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. - * Supports various modes: simple, personalized, split, first_come, riddle - */ -export const giftCodes = giftsSchema.table( - 'gift_codes', - { - id: uuid('id').primaryKey().defaultRandom(), - code: text('code').notNull().unique(), // 6-char code like "ABC123" - shortUrl: text('short_url'), // mana.how/g/ABC123 - - creatorId: text('creator_id') - .notNull() - .references(() => users.id, { onDelete: 'cascade' }), - - // Credit allocation - totalCredits: integer('total_credits').notNull(), // Total reserved credits - creditsPerPortion: integer('credits_per_portion').notNull(), // Credits per redemption - totalPortions: integer('total_portions').notNull().default(1), // Number of portions (1 = simple) - claimedPortions: integer('claimed_portions').notNull().default(0), // Portions redeemed - - // Type and status - type: giftCodeTypeEnum('type').notNull().default('simple'), - status: giftCodeStatusEnum('status').notNull().default('active'), - - // Personalization (for 'personalized' type) - targetEmail: text('target_email'), - targetMatrixId: text('target_matrix_id'), - - // Riddle (for 'riddle' type) - riddleQuestion: text('riddle_question'), - riddleAnswerHash: text('riddle_answer_hash'), // bcrypt hash of answer - - // Message - message: text('message'), - - // Expiration - expiresAt: timestamp('expires_at', { withTimezone: true }), - - // Reference to credit reservation transaction - reservationTransactionId: uuid('reservation_transaction_id'), - - // Timestamps - 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 and success. - */ -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() - .references(() => users.id, { onDelete: 'cascade' }), - - // Redemption result - status: giftRedemptionStatusEnum('status').notNull(), - creditsReceived: integer('credits_received').notNull().default(0), - portionNumber: integer('portion_number'), // Which portion was claimed (for split/first_come) - - // Reference to credit transaction - creditTransactionId: uuid('credit_transaction_id'), - - // Source tracking - sourceAppId: text('source_app_id'), // 'matrix-bot', 'web', etc. - - // Timestamp - 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 -// ============================================ - -/** - * Characters used for gift code generation (uppercase, no ambiguous chars) - */ -export const GIFT_CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; - -/** - * Default gift code length - */ -export const GIFT_CODE_LENGTH = 6; - -/** - * Gift code validation rules - */ -export const GIFT_CODE_RULES = { - minCredits: 1, - maxCredits: 10000, - maxPortions: 100, - maxMessageLength: 500, - maxRiddleQuestionLength: 200, - defaultExpirationDays: 90, -} as const; diff --git a/services/mana-core-auth/src/db/schema/guilds.schema.ts b/services/mana-core-auth/src/db/schema/guilds.schema.ts deleted file mode 100644 index 236eca7da..000000000 --- a/services/mana-core-auth/src/db/schema/guilds.schema.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { uuid, integer, text, timestamp, jsonb, index, unique } from 'drizzle-orm/pg-core'; -import { creditsSchema } from './credits.schema'; -import { organizations } from './organizations.schema'; -import { users } from './auth.schema'; - -/** - * Guild Pool Tables - * - * Shared Mana pools for guilds (Gilden). Members spend directly from the pool - * instead of receiving individual allocations. The Gildenmeister (owner) manages - * funding and optional spending limits per member. - */ - -// Guild Mana pool (one per guild/organization) -export const guildPools = creditsSchema.table('guild_pools', { - organizationId: text('organization_id') - .primaryKey() - .references(() => organizations.id, { onDelete: 'cascade' }), - 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') - .references(() => organizations.id, { onDelete: 'cascade' }) - .notNull(), - userId: text('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - dailyLimit: integer('daily_limit'), // null = unlimited - monthlyLimit: integer('monthly_limit'), // null = unlimited - 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') - .references(() => organizations.id, { onDelete: 'cascade' }) - .notNull(), - userId: text('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - type: text('type').notNull(), // 'funding', 'usage', 'refund' - amount: integer('amount').notNull(), // positive for funding, negative for usage - 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), - // For spending limit queries: user's spending within a guild in a time window - orgUserCreatedIdx: index('guild_transactions_org_user_created_idx').on( - table.organizationId, - table.userId, - table.createdAt - ), - }) -); diff --git a/services/mana-core-auth/src/db/schema/index.ts b/services/mana-core-auth/src/db/schema/index.ts index e0915a5cf..1a34df06b 100644 --- a/services/mana-core-auth/src/db/schema/index.ts +++ b/services/mana-core-auth/src/db/schema/index.ts @@ -1,9 +1,6 @@ export * from './api-keys.schema'; export * from './auth.schema'; -export * from './credits.schema'; export * from './feedback.schema'; -export * from './gifts.schema'; -export * from './guilds.schema'; export * from './login-attempts.schema'; export * from './organizations.schema'; export * from './subscriptions.schema'; diff --git a/services/mana-core-auth/src/gifts/dto/create-gift.dto.ts b/services/mana-core-auth/src/gifts/dto/create-gift.dto.ts deleted file mode 100644 index d0fac0fee..000000000 --- a/services/mana-core-auth/src/gifts/dto/create-gift.dto.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { IsString, IsNumber, IsOptional, IsEmail, Min, Max, MaxLength, IsEnum } from 'class-validator'; - -export type GiftCodeType = 'simple' | 'personalized' | 'split' | 'first_come' | 'riddle'; - -export class CreateGiftDto { - /** - * Total credits to gift - */ - @IsNumber() - @Min(1, { message: 'Minimum 1 credit required' }) - @Max(10000, { message: 'Maximum 10000 credits allowed' }) - credits: number; - - /** - * Gift type - */ - @IsOptional() - @IsEnum(['simple', 'personalized', 'split', 'first_come', 'riddle']) - type?: GiftCodeType; - - /** - * Number of portions (for split/first_come) - * Default: 1 - */ - @IsOptional() - @IsNumber() - @Min(1) - @Max(100) - portions?: number; - - /** - * Target email (for personalized) - */ - @IsOptional() - @IsEmail() - targetEmail?: string; - - /** - * Target Matrix ID (for personalized) - */ - @IsOptional() - @IsString() - targetMatrixId?: string; - - /** - * Riddle question (for riddle type) - */ - @IsOptional() - @IsString() - @MaxLength(200) - riddleQuestion?: string; - - /** - * Riddle answer (will be hashed) - */ - @IsOptional() - @IsString() - @MaxLength(100) - riddleAnswer?: string; - - /** - * Optional message to include - */ - @IsOptional() - @IsString() - @MaxLength(500) - message?: string; - - /** - * Expiration date (ISO string) - */ - @IsOptional() - @IsString() - expiresAt?: string; - - /** - * Source app ID (for tracking) - */ - @IsOptional() - @IsString() - sourceAppId?: string; -} diff --git a/services/mana-core-auth/src/gifts/dto/redeem-gift.dto.ts b/services/mana-core-auth/src/gifts/dto/redeem-gift.dto.ts deleted file mode 100644 index 777046cde..000000000 --- a/services/mana-core-auth/src/gifts/dto/redeem-gift.dto.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { IsString, IsOptional, MaxLength } from 'class-validator'; - -export class RedeemGiftDto { - /** - * Riddle answer (required if gift has a riddle) - */ - @IsOptional() - @IsString() - @MaxLength(100) - answer?: string; - - /** - * Source app ID (for tracking) - */ - @IsOptional() - @IsString() - sourceAppId?: string; -} - -export class GiftCodeInfoResponse { - code: string; - type: string; - status: string; - creditsPerPortion: number; - totalPortions: number; - claimedPortions: number; - remainingPortions: number; - message?: string; - riddleQuestion?: string; - hasRiddle: boolean; - isPersonalized: boolean; - expiresAt?: string; - creatorName?: string; -} - -export class GiftRedeemResponse { - success: boolean; - credits?: number; - message?: string; - error?: string; - newBalance?: number; -} - -export class CreateGiftResponse { - id: string; - code: string; - url: string; - totalCredits: number; - creditsPerPortion: number; - totalPortions: number; - type: string; - expiresAt?: string; -} - -export class GiftListItem { - id: string; - code: string; - url: string; - type: string; - status: string; - totalCredits: number; - creditsPerPortion: number; - totalPortions: number; - claimedPortions: number; - message?: string; - expiresAt?: string; - createdAt: string; -} - -export class ReceivedGiftItem { - id: string; - code: string; - credits: number; - message?: string; - creatorName?: string; - redeemedAt: string; -} diff --git a/services/mana-core-auth/src/gifts/gifts.controller.ts b/services/mana-core-auth/src/gifts/gifts.controller.ts deleted file mode 100644 index d94e10e00..000000000 --- a/services/mana-core-auth/src/gifts/gifts.controller.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { - Controller, - Get, - Post, - Delete, - Body, - Param, - UseGuards, - Request, - HttpCode, - HttpStatus, - NotFoundException, -} from '@nestjs/common'; -import { GiftCodeService } from './services/gift-code.service'; -import { CreateGiftDto } from './dto/create-gift.dto'; -import { RedeemGiftDto } from './dto/redeem-gift.dto'; -import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; - -@Controller('gifts') -export class GiftsController { - constructor(private readonly giftCodeService: GiftCodeService) {} - - /** - * List gift codes created by the authenticated user - * NOTE: This route must come BEFORE :code to avoid 'me' being treated as a code - */ - @Get('me/created') - @UseGuards(JwtAuthGuard) - async listCreatedGifts(@Request() req: any) { - const userId = req.user.sub; - return this.giftCodeService.listCreatedGifts(userId); - } - - /** - * List gifts received by the authenticated user - * NOTE: This route must come BEFORE :code to avoid 'me' being treated as a code - */ - @Get('me/received') - @UseGuards(JwtAuthGuard) - async listReceivedGifts(@Request() req: any) { - const userId = req.user.sub; - return this.giftCodeService.listReceivedGifts(userId); - } - - /** - * Get gift code info (public - no auth required) - * For displaying gift info before redeeming - * NOTE: This dynamic route must come AFTER specific routes like 'me/created' - */ - @Get(':code') - async getGiftInfo(@Param('code') code: string) { - const info = await this.giftCodeService.getGiftCodeInfo(code); - if (!info) { - throw new NotFoundException('Gift code not found'); - } - return info; - } - - /** - * Create a new gift code - */ - @Post() - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.CREATED) - async createGift(@Request() req: any, @Body() dto: CreateGiftDto) { - const userId = req.user.sub; - return this.giftCodeService.createGiftCode(userId, dto); - } - - /** - * Redeem a gift code - */ - @Post(':code/redeem') - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - async redeemGift(@Request() req: any, @Param('code') code: string, @Body() dto: RedeemGiftDto) { - const userId = req.user.sub; - const userEmail = req.user.email; - // Matrix ID would be passed in the request if coming from a Matrix bot - const userMatrixId = req.headers['x-matrix-user-id']; - - return this.giftCodeService.redeemGiftCode(userId, code, dto, userEmail, userMatrixId); - } - - /** - * Cancel a gift code and get refund for unclaimed portions - */ - @Delete(':id') - @UseGuards(JwtAuthGuard) - @HttpCode(HttpStatus.OK) - async cancelGift(@Request() req: any, @Param('id') id: string) { - const userId = req.user.sub; - return this.giftCodeService.cancelGiftCode(userId, id); - } -} diff --git a/services/mana-core-auth/src/gifts/gifts.module.ts b/services/mana-core-auth/src/gifts/gifts.module.ts deleted file mode 100644 index fbcdc56f6..000000000 --- a/services/mana-core-auth/src/gifts/gifts.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { GiftsController } from './gifts.controller'; -import { GiftCodeService } from './services/gift-code.service'; - -@Module({ - controllers: [GiftsController], - providers: [GiftCodeService], - exports: [GiftCodeService], -}) -export class GiftsModule {} diff --git a/services/mana-core-auth/src/gifts/services/gift-code.service.ts b/services/mana-core-auth/src/gifts/services/gift-code.service.ts deleted file mode 100644 index cd20b2b27..000000000 --- a/services/mana-core-auth/src/gifts/services/gift-code.service.ts +++ /dev/null @@ -1,737 +0,0 @@ -import { - Injectable, - BadRequestException, - NotFoundException, - ForbiddenException, - ConflictException, - Logger, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { eq, and, desc, sql } from 'drizzle-orm'; -import * as bcrypt from 'bcryptjs'; -import { getDb } from '../../db/connection'; -import { - giftCodes, - giftRedemptions, - GIFT_CODE_CHARS, - GIFT_CODE_LENGTH, - GIFT_CODE_RULES, -} from '../../db/schema/gifts.schema'; -import { balances, transactions, users } from '../../db/schema'; -import { CreateGiftDto, GiftCodeType } from '../dto/create-gift.dto'; -import { - RedeemGiftDto, - GiftCodeInfoResponse, - GiftRedeemResponse, - CreateGiftResponse, - GiftListItem, - ReceivedGiftItem, -} from '../dto/redeem-gift.dto'; - -@Injectable() -export class GiftCodeService { - private readonly logger = new Logger(GiftCodeService.name); - - constructor(private configService: ConfigService) {} - - private getDb() { - const databaseUrl = this.configService.get('database.url'); - return getDb(databaseUrl!); - } - - /** - * Generate a unique 6-character gift code - */ - private async generateUniqueCode(): Promise { - const db = this.getDb(); - let attempts = 0; - const maxAttempts = 10; - - while (attempts < maxAttempts) { - // Generate random code - let code = ''; - for (let i = 0; i < GIFT_CODE_LENGTH; i++) { - code += GIFT_CODE_CHARS[Math.floor(Math.random() * GIFT_CODE_CHARS.length)]; - } - - // Check if code exists - const [existing] = await db - .select({ id: giftCodes.id }) - .from(giftCodes) - .where(eq(giftCodes.code, code)) - .limit(1); - - if (!existing) { - return code; - } - - attempts++; - } - - throw new Error('Failed to generate unique code after max attempts'); - } - - /** - * Create a new gift code - */ - async createGiftCode(userId: string, dto: CreateGiftDto): Promise { - const db = this.getDb(); - - // Validate credits - if (dto.credits < GIFT_CODE_RULES.minCredits) { - throw new BadRequestException(`Minimum ${GIFT_CODE_RULES.minCredits} credits required`); - } - if (dto.credits > GIFT_CODE_RULES.maxCredits) { - throw new BadRequestException(`Maximum ${GIFT_CODE_RULES.maxCredits} credits allowed`); - } - - // Determine gift type and portions - const type: GiftCodeType = dto.type || 'simple'; - const portions = dto.portions || 1; - - if (portions > GIFT_CODE_RULES.maxPortions) { - throw new BadRequestException(`Maximum ${GIFT_CODE_RULES.maxPortions} portions allowed`); - } - - // Calculate credits per portion - const creditsPerPortion = Math.floor(dto.credits / portions); - const totalCredits = creditsPerPortion * portions; - - if (creditsPerPortion < 1) { - throw new BadRequestException('Each portion must have at least 1 credit'); - } - - // Validate riddle if provided - if (type === 'riddle' && (!dto.riddleQuestion || !dto.riddleAnswer)) { - throw new BadRequestException('Riddle type requires both question and answer'); - } - - // Hash riddle answer if provided - let riddleAnswerHash: string | null = null; - if (dto.riddleAnswer) { - riddleAnswerHash = await bcrypt.hash(dto.riddleAnswer.toLowerCase().trim(), 10); - } - - // Calculate expiration - let expiresAt: Date | null = null; - if (dto.expiresAt) { - expiresAt = new Date(dto.expiresAt); - if (expiresAt <= new Date()) { - throw new BadRequestException('Expiration date must be in the future'); - } - } else { - // Default expiration - expiresAt = new Date(); - expiresAt.setDate(expiresAt.getDate() + GIFT_CODE_RULES.defaultExpirationDays); - } - - return await db.transaction(async (tx) => { - // 1. Get user balance with row lock - const [userBalance] = await tx - .select() - .from(balances) - .where(eq(balances.userId, userId)) - .for('update') - .limit(1); - - if (!userBalance) { - throw new NotFoundException('User balance not found'); - } - - if (userBalance.balance < totalCredits) { - throw new BadRequestException( - `Insufficient credits. Required: ${totalCredits}, Available: ${userBalance.balance}` - ); - } - - // 2. Generate unique code - const code = await this.generateUniqueCode(); - - // 3. Deduct credits from user (reserve them) - const newBalance = userBalance.balance - totalCredits; - - const updateResult = await tx - .update(balances) - .set({ - balance: newBalance, - version: userBalance.version + 1, - updatedAt: new Date(), - }) - .where(and(eq(balances.userId, userId), eq(balances.version, userBalance.version))) - .returning(); - - if (updateResult.length === 0) { - throw new ConflictException('Balance was modified. Please retry.'); - } - - // 4. Create reservation transaction - const [reservationTx] = await tx - .insert(transactions) - .values({ - userId, - type: 'gift', - status: 'completed', - amount: -totalCredits, - balanceBefore: userBalance.balance, - balanceAfter: newBalance, - appId: dto.sourceAppId || 'gift', - description: `Gift code reservation: ${code}`, - completedAt: new Date(), - }) - .returning(); - - // 5. Create gift code - const baseUrl = this.configService.get('app.baseUrl') || 'https://mana.how'; - const shortUrl = `${baseUrl}/g/${code}`; - - const [giftCode] = await tx - .insert(giftCodes) - .values({ - code, - shortUrl, - creatorId: userId, - totalCredits, - creditsPerPortion, - totalPortions: portions, - claimedPortions: 0, - type, - status: 'active', - targetEmail: dto.targetEmail || null, - targetMatrixId: dto.targetMatrixId || null, - riddleQuestion: dto.riddleQuestion || null, - riddleAnswerHash, - message: dto.message || null, - expiresAt, - reservationTransactionId: reservationTx.id, - }) - .returning(); - - this.logger.log('Gift code created', { - codeId: giftCode.id, - code, - userId, - totalCredits, - type, - }); - - return { - id: giftCode.id, - code: giftCode.code, - url: shortUrl, - totalCredits, - creditsPerPortion, - totalPortions: portions, - type, - expiresAt: expiresAt?.toISOString(), - }; - }); - } - - /** - * Get gift code info (public, for preview before redeeming) - */ - async getGiftCodeInfo(code: string): Promise { - const db = this.getDb(); - - const [giftCode] = await db - .select({ - code: giftCodes.code, - type: giftCodes.type, - status: giftCodes.status, - creditsPerPortion: giftCodes.creditsPerPortion, - totalPortions: giftCodes.totalPortions, - claimedPortions: giftCodes.claimedPortions, - message: giftCodes.message, - riddleQuestion: giftCodes.riddleQuestion, - targetEmail: giftCodes.targetEmail, - targetMatrixId: giftCodes.targetMatrixId, - expiresAt: giftCodes.expiresAt, - creatorId: giftCodes.creatorId, - }) - .from(giftCodes) - .where(eq(giftCodes.code, code.toUpperCase())) - .limit(1); - - if (!giftCode) { - return null; - } - - // Get creator name - const [creator] = await db - .select({ name: users.name }) - .from(users) - .where(eq(users.id, giftCode.creatorId)) - .limit(1); - - return { - code: giftCode.code, - type: giftCode.type, - status: giftCode.status, - creditsPerPortion: giftCode.creditsPerPortion, - totalPortions: giftCode.totalPortions, - claimedPortions: giftCode.claimedPortions, - remainingPortions: giftCode.totalPortions - giftCode.claimedPortions, - message: giftCode.message || undefined, - riddleQuestion: giftCode.riddleQuestion || undefined, - hasRiddle: !!giftCode.riddleQuestion, - isPersonalized: !!(giftCode.targetEmail || giftCode.targetMatrixId), - expiresAt: giftCode.expiresAt?.toISOString(), - creatorName: creator?.name || undefined, - }; - } - - /** - * Redeem a gift code - */ - async redeemGiftCode( - userId: string, - code: string, - dto: RedeemGiftDto, - userEmail?: string, - userMatrixId?: string - ): Promise { - const db = this.getDb(); - - return await db.transaction(async (tx) => { - // 1. Get gift code with row lock - const [giftCode] = await tx - .select() - .from(giftCodes) - .where(eq(giftCodes.code, code.toUpperCase())) - .for('update') - .limit(1); - - if (!giftCode) { - return { success: false, error: 'Gift code not found' }; - } - - // 2. Check status - if (giftCode.status !== 'active') { - const statusMessages: Record = { - depleted: 'This gift code has been fully claimed', - expired: 'This gift code has expired', - cancelled: 'This gift code has been cancelled', - refunded: 'This gift code has been refunded', - }; - return { - success: false, - error: statusMessages[giftCode.status] || 'Gift code is not active', - }; - } - - // 3. Check expiration - if (giftCode.expiresAt && giftCode.expiresAt < new Date()) { - // Update status to expired - await tx - .update(giftCodes) - .set({ status: 'expired', updatedAt: new Date() }) - .where(eq(giftCodes.id, giftCode.id)); - - return { success: false, error: 'This gift code has expired' }; - } - - // 4. Check if depleted - if (giftCode.claimedPortions >= giftCode.totalPortions) { - await tx - .update(giftCodes) - .set({ status: 'depleted', updatedAt: new Date() }) - .where(eq(giftCodes.id, giftCode.id)); - - return { success: false, error: 'This gift code has been fully claimed' }; - } - - // 5. Check personalization - if (giftCode.targetEmail || giftCode.targetMatrixId) { - const emailMatch = - giftCode.targetEmail && userEmail?.toLowerCase() === giftCode.targetEmail.toLowerCase(); - const matrixMatch = giftCode.targetMatrixId && userMatrixId === giftCode.targetMatrixId; - - if (!emailMatch && !matrixMatch) { - // Record failed attempt - await tx.insert(giftRedemptions).values({ - giftCodeId: giftCode.id, - redeemerUserId: userId, - status: 'failed_wrong_user', - creditsReceived: 0, - portionNumber: null, - creditTransactionId: null, - sourceAppId: dto.sourceAppId ?? null, - }); - - return { success: false, error: 'This gift code is for a specific person' }; - } - } - - // 6. Check riddle answer - if (giftCode.riddleAnswerHash) { - if (!dto.answer) { - return { success: false, error: 'Please provide the answer to the riddle' }; - } - - const isCorrect = await bcrypt.compare( - dto.answer.toLowerCase().trim(), - giftCode.riddleAnswerHash - ); - if (!isCorrect) { - // Record failed attempt - await tx.insert(giftRedemptions).values({ - giftCodeId: giftCode.id, - redeemerUserId: userId, - status: 'failed_wrong_answer', - creditsReceived: 0, - portionNumber: null, - creditTransactionId: null, - sourceAppId: dto.sourceAppId ?? null, - }); - - return { success: false, error: 'Incorrect answer' }; - } - } - - // 7. Check if user already claimed (for most types except 'split') - if ( - giftCode.type === 'simple' || - giftCode.type === 'personalized' || - giftCode.type === 'riddle' || - giftCode.type === 'first_come' - ) { - const [existingClaim] = await tx - .select({ id: giftRedemptions.id }) - .from(giftRedemptions) - .where( - and( - eq(giftRedemptions.giftCodeId, giftCode.id), - eq(giftRedemptions.redeemerUserId, userId), - eq(giftRedemptions.status, 'success') - ) - ) - .limit(1); - - if (existingClaim) { - return { success: false, error: 'You have already claimed this gift' }; - } - } - - // 8. Get or create redeemer balance - let [redeemerBalance] = await tx - .select() - .from(balances) - .where(eq(balances.userId, userId)) - .for('update') - .limit(1); - - if (!redeemerBalance) { - // Initialize balance (starts at 0) - [redeemerBalance] = await tx - .insert(balances) - .values({ - userId, - balance: 0, - totalEarned: 0, - totalSpent: 0, - }) - .returning(); - } - - // 9. Add credits to redeemer - const creditsToAdd = giftCode.creditsPerPortion; - const newBalance = redeemerBalance.balance + creditsToAdd; - const portionNumber = giftCode.claimedPortions + 1; - - await tx - .update(balances) - .set({ - balance: newBalance, - totalEarned: redeemerBalance.totalEarned + creditsToAdd, - version: redeemerBalance.version + 1, - updatedAt: new Date(), - }) - .where(eq(balances.userId, userId)); - - // 10. Create credit transaction for receiver - const [creditTx] = await tx - .insert(transactions) - .values({ - userId, - type: 'gift', - status: 'completed', - amount: creditsToAdd, - balanceBefore: redeemerBalance.balance, - balanceAfter: newBalance, - appId: dto.sourceAppId || 'gift', - description: `Gift received: ${giftCode.code}`, - metadata: { giftCodeId: giftCode.id, portionNumber }, - idempotencyKey: null, - completedAt: new Date(), - }) - .returning(); - - // 11. Update gift code - const newClaimedPortions = giftCode.claimedPortions + 1; - const newStatus = newClaimedPortions >= giftCode.totalPortions ? 'depleted' : 'active'; - - await tx - .update(giftCodes) - .set({ - claimedPortions: newClaimedPortions, - status: newStatus, - updatedAt: new Date(), - }) - .where(eq(giftCodes.id, giftCode.id)); - - // 12. Record successful redemption - await tx.insert(giftRedemptions).values({ - giftCodeId: giftCode.id, - redeemerUserId: userId, - status: 'success', - creditsReceived: creditsToAdd, - portionNumber, - creditTransactionId: creditTx.id, - sourceAppId: dto.sourceAppId ?? null, - }); - - this.logger.log('Gift code redeemed', { - code: giftCode.code, - redeemerUserId: userId, - credits: creditsToAdd, - portionNumber, - }); - - return { - success: true, - credits: creditsToAdd, - message: giftCode.message || undefined, - newBalance, - }; - }); - } - - /** - * Cancel a gift code and refund remaining credits - */ - async cancelGiftCode(userId: string, codeId: string): Promise<{ refundedCredits: number }> { - const db = this.getDb(); - - return await db.transaction(async (tx) => { - // 1. Get gift code with row lock - const [giftCode] = await tx - .select() - .from(giftCodes) - .where(and(eq(giftCodes.id, codeId), eq(giftCodes.creatorId, userId))) - .for('update') - .limit(1); - - if (!giftCode) { - throw new NotFoundException('Gift code not found'); - } - - if (giftCode.status !== 'active') { - throw new BadRequestException('Gift code cannot be cancelled in current status'); - } - - // 2. Calculate refund - const unclaimedPortions = giftCode.totalPortions - giftCode.claimedPortions; - const refundAmount = unclaimedPortions * giftCode.creditsPerPortion; - - if (refundAmount > 0) { - // 3. Get creator balance - const [creatorBalance] = await tx - .select() - .from(balances) - .where(eq(balances.userId, userId)) - .for('update') - .limit(1); - - if (!creatorBalance) { - throw new NotFoundException('Creator balance not found'); - } - - // 4. Refund credits - const newBalance = creatorBalance.balance + refundAmount; - - await tx - .update(balances) - .set({ - balance: newBalance, - totalEarned: creatorBalance.totalEarned + refundAmount, - version: creatorBalance.version + 1, - updatedAt: new Date(), - }) - .where(eq(balances.userId, userId)); - - // 5. Create refund transaction - await tx.insert(transactions).values({ - userId, - type: 'refund', - status: 'completed', - amount: refundAmount, - balanceBefore: creatorBalance.balance, - balanceAfter: newBalance, - appId: 'gift', - description: `Gift code cancelled: ${giftCode.code}`, - metadata: { giftCodeId: giftCode.id }, - completedAt: new Date(), - }); - } - - // 6. Update gift code status - const newStatus = giftCode.claimedPortions > 0 ? 'cancelled' : 'refunded'; - - await tx - .update(giftCodes) - .set({ - status: newStatus, - updatedAt: new Date(), - }) - .where(eq(giftCodes.id, giftCode.id)); - - this.logger.log('Gift code cancelled', { - codeId: giftCode.id, - code: giftCode.code, - refundedCredits: refundAmount, - }); - - return { refundedCredits: refundAmount }; - }); - } - - /** - * List gift codes created by a user - */ - async listCreatedGifts(userId: string): Promise { - const db = this.getDb(); - - const codes = await db - .select({ - id: giftCodes.id, - code: giftCodes.code, - shortUrl: giftCodes.shortUrl, - type: giftCodes.type, - status: giftCodes.status, - totalCredits: giftCodes.totalCredits, - creditsPerPortion: giftCodes.creditsPerPortion, - totalPortions: giftCodes.totalPortions, - claimedPortions: giftCodes.claimedPortions, - message: giftCodes.message, - expiresAt: giftCodes.expiresAt, - createdAt: giftCodes.createdAt, - }) - .from(giftCodes) - .where(eq(giftCodes.creatorId, userId)) - .orderBy(desc(giftCodes.createdAt)) - .limit(50); - - return codes.map((code) => ({ - id: code.id, - code: code.code, - url: code.shortUrl || `https://mana.how/g/${code.code}`, - type: code.type, - status: code.status, - totalCredits: code.totalCredits, - creditsPerPortion: code.creditsPerPortion, - totalPortions: code.totalPortions, - claimedPortions: code.claimedPortions, - message: code.message || undefined, - expiresAt: code.expiresAt?.toISOString(), - createdAt: code.createdAt.toISOString(), - })); - } - - /** - * List gifts received by a user - */ - async listReceivedGifts(userId: string): Promise { - const db = this.getDb(); - - const redemptions = await db - .select({ - id: giftRedemptions.id, - code: giftCodes.code, - credits: giftRedemptions.creditsReceived, - message: giftCodes.message, - creatorId: giftCodes.creatorId, - redeemedAt: giftRedemptions.createdAt, - }) - .from(giftRedemptions) - .innerJoin(giftCodes, eq(giftRedemptions.giftCodeId, giftCodes.id)) - .where(and(eq(giftRedemptions.redeemerUserId, userId), eq(giftRedemptions.status, 'success'))) - .orderBy(desc(giftRedemptions.createdAt)) - .limit(50); - - // Get creator names - const creatorIds = [...new Set(redemptions.map((r) => r.creatorId))]; - const creators = - creatorIds.length > 0 - ? await db - .select({ id: users.id, name: users.name }) - .from(users) - .where(sql`${users.id} = ANY(${creatorIds})`) - : []; - - const creatorMap = new Map(creators.map((c) => [c.id, c.name])); - - return redemptions.map((r) => ({ - id: r.id, - code: r.code, - credits: r.credits, - message: r.message || undefined, - creatorName: creatorMap.get(r.creatorId) || undefined, - redeemedAt: r.redeemedAt.toISOString(), - })); - } - - /** - * Automatically redeem pending gifts for a newly registered user - * Called during registration to give users any gifts sent to their email before signup - */ - async redeemPendingGifts( - userId: string, - email: string - ): Promise<{ redeemedCount: number; totalCredits: number }> { - const db = this.getDb(); - - // Find active gift codes with targetEmail matching the user's email - const pendingGifts = await db - .select() - .from(giftCodes) - .where(and(eq(giftCodes.targetEmail, email.toLowerCase()), eq(giftCodes.status, 'active'))); - - if (pendingGifts.length === 0) { - return { redeemedCount: 0, totalCredits: 0 }; - } - - let redeemedCount = 0; - let totalCredits = 0; - - // Redeem each pending gift - for (const gift of pendingGifts) { - try { - const result = await this.redeemGiftCode( - userId, - gift.code, - { sourceAppId: 'registration' }, - email - ); - - if (result.success) { - redeemedCount++; - totalCredits += result.credits || 0; - this.logger.log('Auto-redeemed pending gift on registration', { - userId, - code: gift.code, - credits: result.credits, - }); - } - } catch (error) { - this.logger.warn('Failed to auto-redeem pending gift', { - userId, - code: gift.code, - error: error instanceof Error ? error.message : 'Unknown error', - }); - // Continue with other gifts even if one fails - } - } - - return { redeemedCount, totalCredits }; - } -} diff --git a/services/mana-core-auth/src/guilds/guilds.module.ts b/services/mana-core-auth/src/guilds/guilds.module.ts index be1660a1a..0b35779f4 100644 --- a/services/mana-core-auth/src/guilds/guilds.module.ts +++ b/services/mana-core-auth/src/guilds/guilds.module.ts @@ -2,10 +2,9 @@ import { Module, forwardRef } from '@nestjs/common'; import { GuildsController } from './guilds.controller'; import { GuildsService } from './guilds.service'; import { AuthModule } from '../auth/auth.module'; -import { CreditsModule } from '../credits/credits.module'; @Module({ - imports: [forwardRef(() => AuthModule), forwardRef(() => CreditsModule)], + imports: [forwardRef(() => AuthModule)], controllers: [GuildsController], providers: [GuildsService], exports: [GuildsService], diff --git a/services/mana-core-auth/src/guilds/guilds.service.ts b/services/mana-core-auth/src/guilds/guilds.service.ts index 018eb7352..6b955a570 100644 --- a/services/mana-core-auth/src/guilds/guilds.service.ts +++ b/services/mana-core-auth/src/guilds/guilds.service.ts @@ -5,7 +5,6 @@ import { getDb } from '../db/connection'; import { members, organizations } from '../db/schema'; import { subscriptions, plans } from '../db/schema/subscriptions.schema'; import { BetterAuthService } from '../auth/services/better-auth.service'; -import { GuildPoolService } from '../credits/guild-pool.service'; import { UpdateOrganizationDto } from '../auth/dto/update-organization.dto'; export class CreateGuildDto { @@ -26,10 +25,36 @@ export class GuildsService { constructor( private configService: ConfigService, - private betterAuthService: BetterAuthService, - private guildPoolService: GuildPoolService + private betterAuthService: BetterAuthService ) {} + /** Get mana-credits service URL */ + private getCreditsUrl(): string { + return process.env.MANA_CREDITS_URL || 'http://localhost:3060'; + } + + private getServiceKey(): string { + return process.env.MANA_CORE_SERVICE_KEY || ''; + } + + /** Call mana-credits to get guild pool balance */ + private async getGuildPoolBalance(guildId: string, userId: string) { + try { + const creditsUrl = this.getCreditsUrl(); + // Use internal API with service key to get pool balance on behalf of user + const res = await fetch( + `${creditsUrl}/api/v1/internal/guild-pool/balance?guildId=${guildId}&userId=${userId}`, + { + headers: { 'X-Service-Key': this.getServiceKey() }, + } + ); + if (res.ok) return await res.json(); + } catch (error) { + this.logger.warn('Failed to get guild pool balance from mana-credits', { guildId }); + } + return { balance: 0, totalFunded: 0, totalSpent: 0 }; + } + private getDb() { const databaseUrl = this.configService.get('database.url'); return getDb(databaseUrl!); @@ -109,7 +134,19 @@ export class GuildsService { }); // Initialize the guild pool - const pool = await this.guildPoolService.initializeGuildPool(result.id); + // Initialize guild pool via mana-credits + let pool = { balance: 0, totalFunded: 0, totalSpent: 0 }; + try { + const creditsUrl = this.getCreditsUrl(); + const res = await fetch(`${creditsUrl}/api/v1/internal/guild-pool/init`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Service-Key': this.getServiceKey() }, + body: JSON.stringify({ organizationId: result.id }), + }); + if (res.ok) pool = await res.json(); + } catch { + this.logger.warn('Failed to init guild pool (non-critical)', { guildId: result.id }); + } this.logger.log('Guild created', { guildId: result.id, name: dto.name }); @@ -139,7 +176,7 @@ export class GuildsService { for (const org of result.organizations || []) { try { - const pool = await this.guildPoolService.getGuildPoolBalance(org.id, userId); + const pool = await this.getGuildPoolBalance(org.id, userId); guilds.push({ gilde: { id: org.id, @@ -177,7 +214,7 @@ export class GuildsService { let pool = null; try { - pool = await this.guildPoolService.getGuildPoolBalance(guildId, userId); + pool = await this.getGuildPoolBalance(guildId, userId); } catch { // Pool might not exist } @@ -214,7 +251,13 @@ export class GuildsService { * Invite a member to the guild. * Enforces subscription limit for maxTeamMembers. */ - async inviteMember(guildId: string, email: string, role: string, inviterUserId: string, token: string) { + async inviteMember( + guildId: string, + email: string, + role: string, + inviterUserId: string, + token: string + ) { // Find guild owner to check their subscription limits const db = this.getDb(); const [owner] = await db diff --git a/services/mana-core-auth/src/stripe/stripe-webhook.controller.ts b/services/mana-core-auth/src/stripe/stripe-webhook.controller.ts index 2360ae8e9..a469d8b49 100644 --- a/services/mana-core-auth/src/stripe/stripe-webhook.controller.ts +++ b/services/mana-core-auth/src/stripe/stripe-webhook.controller.ts @@ -9,17 +9,22 @@ import { Inject, forwardRef, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse, ApiExcludeEndpoint } from '@nestjs/swagger'; +import { ApiTags, ApiExcludeEndpoint } from '@nestjs/swagger'; import type { Request } from 'express'; import type Stripe from 'stripe'; import { StripeService } from './stripe.service'; -import { CreditsService } from '../credits/credits.service'; import { SubscriptionsService } from '../subscriptions/subscriptions.service'; interface RawBodyRequest extends Request { rawBody?: Buffer; } +/** + * Stripe Webhook Controller — Subscription events only. + * + * Credit-related events (payment_intent.*, checkout.session.*) are handled + * by the standalone mana-credits service. + */ @ApiTags('webhooks') @Controller('webhooks/stripe') export class StripeWebhookController { @@ -27,32 +32,18 @@ export class StripeWebhookController { constructor( private stripeService: StripeService, - @Inject(forwardRef(() => CreditsService)) - private creditsService: CreditsService, @Inject(forwardRef(() => SubscriptionsService)) private subscriptionsService: SubscriptionsService ) {} @Post() @HttpCode(200) - @ApiExcludeEndpoint() // Hide from Swagger - internal webhook - @ApiOperation({ summary: 'Handle Stripe webhooks' }) - @ApiResponse({ status: 200, description: 'Webhook processed' }) - @ApiResponse({ status: 400, description: 'Invalid webhook signature' }) + @ApiExcludeEndpoint() async handleWebhook(@Req() req: RawBodyRequest, @Headers('stripe-signature') signature: string) { const rawBody = req.rawBody; + if (!rawBody) throw new BadRequestException('Missing raw body'); + if (!signature) throw new BadRequestException('Missing stripe-signature header'); - if (!rawBody) { - this.logger.warn('Webhook received without raw body'); - throw new BadRequestException('Missing raw body'); - } - - if (!signature) { - this.logger.warn('Webhook received without signature'); - throw new BadRequestException('Missing stripe-signature header'); - } - - // Verify signature and parse event let event: Stripe.Event; try { event = this.stripeService.verifyWebhookSignature(rawBody, signature); @@ -63,40 +54,9 @@ export class StripeWebhookController { throw new BadRequestException('Invalid webhook signature'); } - this.logger.log('Webhook received', { - type: event.type, - id: event.id, - }); + this.logger.log('Webhook received', { type: event.type, id: event.id }); - // Handle relevant events - // Note: SEPA Direct Debit payments are not instant - they go through: - // 1. checkout.session.completed (payment_status may be 'unpaid' for SEPA) - // 2. payment_intent.processing (SEPA is being processed by banks) - // 3. payment_intent.succeeded (3-14 days later when bank confirms) - // Credits are only added on payment_intent.succeeded for safety. switch (event.type) { - // Credit purchases via Checkout Session - case 'checkout.session.completed': - await this.handleCheckoutSessionCompleted(event.data.object as Stripe.Checkout.Session); - break; - - // Payment processing (SEPA: bank is processing the debit) - case 'payment_intent.processing': - this.logger.log('Payment processing (SEPA in progress)', { - paymentIntentId: (event.data.object as Stripe.PaymentIntent).id, - }); - // Purchase stays in 'pending' status until succeeded - break; - - // Credit purchases - payment confirmed - case 'payment_intent.succeeded': - await this.handlePaymentSucceeded(event.data.object as Stripe.PaymentIntent); - break; - - case 'payment_intent.payment_failed': - await this.handlePaymentFailed(event.data.object as Stripe.PaymentIntent); - break; - // Subscriptions case 'customer.subscription.created': case 'customer.subscription.updated': @@ -119,103 +79,6 @@ export class StripeWebhookController { return { received: true }; } - private async handlePaymentSucceeded(paymentIntent: Stripe.PaymentIntent) { - this.logger.log('Processing payment success', { - paymentIntentId: paymentIntent.id, - amount: paymentIntent.amount, - customer: paymentIntent.customer, - }); - - try { - const result = await this.creditsService.completePurchase(paymentIntent.id); - - if (result.alreadyProcessed) { - this.logger.log('Purchase already processed (idempotent)', { - paymentIntentId: paymentIntent.id, - }); - } else { - this.logger.log('Purchase completed successfully', { - paymentIntentId: paymentIntent.id, - creditsAdded: result.creditsAdded, - }); - } - } catch (error) { - this.logger.error('Failed to complete purchase', { - paymentIntentId: paymentIntent.id, - error: error instanceof Error ? error.message : 'Unknown error', - }); - // Rethrow to return 500 to Stripe for retry - throw error; - } - } - - private async handlePaymentFailed(paymentIntent: Stripe.PaymentIntent) { - const failureMessage = paymentIntent.last_payment_error?.message || 'Payment failed'; - - this.logger.log('Processing payment failure', { - paymentIntentId: paymentIntent.id, - failureMessage, - }); - - try { - await this.creditsService.failPurchase(paymentIntent.id, failureMessage); - } catch (error) { - this.logger.error('Failed to mark purchase as failed', { - paymentIntentId: paymentIntent.id, - error: error instanceof Error ? error.message : 'Unknown error', - }); - } - } - - private async handleCheckoutSessionCompleted(session: Stripe.Checkout.Session) { - this.logger.log('Processing checkout session completed', { - sessionId: session.id, - paymentIntentId: session.payment_intent, - purchaseId: session.metadata?.purchaseId, - }); - - // For Checkout Sessions, we need to update the purchase with the PaymentIntent ID - // so that the payment_intent.succeeded handler can process it - const purchaseId = session.metadata?.purchaseId; - const paymentIntentId = session.payment_intent as string; - - if (purchaseId && paymentIntentId) { - try { - await this.creditsService.updatePurchasePaymentIntent(purchaseId, paymentIntentId); - this.logger.log('Updated purchase with PaymentIntent ID', { - purchaseId, - paymentIntentId, - }); - } catch (error) { - this.logger.error('Failed to update purchase with PaymentIntent ID', { - purchaseId, - paymentIntentId, - error: error instanceof Error ? error.message : 'Unknown error', - }); - } - } - - // If payment_status is 'paid', complete the purchase immediately - if (session.payment_status === 'paid' && paymentIntentId) { - try { - const result = await this.creditsService.completePurchase(paymentIntentId); - if (result.alreadyProcessed) { - this.logger.log('Purchase already processed', { sessionId: session.id }); - } else { - this.logger.log('Purchase completed via checkout session', { - sessionId: session.id, - creditsAdded: result.creditsAdded, - }); - } - } catch (error) { - this.logger.error('Failed to complete purchase from checkout session', { - sessionId: session.id, - error: error instanceof Error ? error.message : 'Unknown error', - }); - } - } - } - private async handleSubscriptionUpdated(subscription: Stripe.Subscription) { this.logger.log('Processing subscription update', { subscriptionId: subscription.id, diff --git a/services/mana-core-auth/src/stripe/stripe.module.ts b/services/mana-core-auth/src/stripe/stripe.module.ts index fcca989ec..8bb89eaa2 100644 --- a/services/mana-core-auth/src/stripe/stripe.module.ts +++ b/services/mana-core-auth/src/stripe/stripe.module.ts @@ -1,11 +1,10 @@ import { Module, forwardRef } from '@nestjs/common'; import { StripeService } from './stripe.service'; import { StripeWebhookController } from './stripe-webhook.controller'; -import { CreditsModule } from '../credits/credits.module'; import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; @Module({ - imports: [forwardRef(() => CreditsModule), forwardRef(() => SubscriptionsModule)], + imports: [forwardRef(() => SubscriptionsModule)], controllers: [StripeWebhookController], providers: [StripeService], exports: [StripeService],