refactor(auth): remove credits, gifts, and credit-webhooks from mana-core-auth

Remove ~4,200 lines of credit-related code now handled by mana-credits:

Deleted modules:
- credits/ (service, controller, DTOs, specs, guild-pool) — 2,590 LOC
- gifts/ (service, controller, DTOs) — 1,001 LOC
- db/schema/credits.schema.ts, gifts.schema.ts, guilds.schema.ts — 419 LOC

Updated modules:
- app.module.ts: Remove CreditsModule, GiftsModule imports
- stripe.module.ts: Remove CreditsModule dependency (keep for subscriptions)
- stripe-webhook.controller.ts: Remove credit event handlers, keep only
  subscription/invoice events
- guilds.module.ts: Remove CreditsModule dependency
- guilds.service.ts: Replace GuildPoolService with HTTP calls to mana-credits
- better-auth.service.ts: Remove GiftCodeService injection, clean up
  unused imports (Inject, forwardRef, Optional)
- db/schema/index.ts: Remove credit/gift/guild schema exports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-27 22:19:42 +01:00
parent 3e2558a63a
commit c07987138e
27 changed files with 63 additions and 4185 deletions

View file

@ -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,

View file

@ -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');

View file

@ -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<CreditsService>;
// 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>(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
});

View file

@ -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,
});
}
}

View file

@ -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 {}

View file

@ -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>(CreditsService);
configService = module.get<ConfigService>(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
});

View file

@ -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<string>('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<void> {
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<string, unknown>) || {}),
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<void> {
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<string>('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<string, unknown>) || {}),
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,
},
};
}
}

View file

@ -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
}

View file

@ -1,11 +0,0 @@
import { IsInt, IsPositive, IsOptional, IsString } from 'class-validator';
export class FundGuildPoolDto {
@IsInt()
@IsPositive()
amount: number;
@IsString()
@IsOptional()
idempotencyKey?: string;
}

View file

@ -1,9 +0,0 @@
import { IsUUID, IsOptional } from 'class-validator';
export class PurchaseCreditsDto {
@IsUUID()
packageId: string;
@IsOptional()
metadata?: Record<string, any>;
}

View file

@ -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;
}

View file

@ -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<string, any>;
@IsOptional()
@ValidateNested()
@Type(() => CreditSourceDto)
creditSource?: CreditSourceDto;
}

View file

@ -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<string>('database.url');
return getDb(databaseUrl!);
}
/**
* Verify user is a member of the guild. Returns the member record.
*/
private async verifyMembership(db: ReturnType<typeof getDb>, 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<typeof getDb>, 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<typeof getDb>,
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<number>`COALESCE(SUM(ABS(${guildTransactions.amount})), 0)`,
})
.from(guildTransactions)
.where(
and(
eq(guildTransactions.organizationId, guildId),
eq(guildTransactions.userId, userId),
eq(guildTransactions.type, 'usage'),
gte(guildTransactions.createdAt, 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<number>`COALESCE(SUM(ABS(${guildTransactions.amount})), 0)`,
})
.from(guildTransactions)
.where(
and(
eq(guildTransactions.organizationId, guildId),
eq(guildTransactions.userId, userId),
eq(guildTransactions.type, 'usage'),
gte(guildTransactions.createdAt, 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<number>`COALESCE(SUM(ABS(${guildTransactions.amount})), 0)`,
})
.from(guildTransactions)
.where(
and(
eq(guildTransactions.organizationId, guildId),
eq(guildTransactions.userId, userId),
eq(guildTransactions.type, 'usage'),
gte(guildTransactions.createdAt, startOfDay)
)
);
const [monthlySpending] = await db
.select({
total: sql<number>`COALESCE(SUM(ABS(${guildTransactions.amount})), 0)`,
})
.from(guildTransactions)
.where(
and(
eq(guildTransactions.organizationId, guildId),
eq(guildTransactions.userId, userId),
eq(guildTransactions.type, 'usage'),
gte(guildTransactions.createdAt, 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,
};
}
}

View file

@ -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
);
}
}

View file

@ -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

View file

@ -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;

View file

@ -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
),
})
);

View file

@ -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';

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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);
}
}

View file

@ -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 {}

View file

@ -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<string>('database.url');
return getDb(databaseUrl!);
}
/**
* Generate a unique 6-character gift code
*/
private async generateUniqueCode(): Promise<string> {
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<CreateGiftResponse> {
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<string>('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<GiftCodeInfoResponse | null> {
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<GiftRedeemResponse> {
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<string, string> = {
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<GiftListItem[]> {
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<ReceivedGiftItem[]> {
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 };
}
}

View file

@ -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],

View file

@ -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<string>('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

View file

@ -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,

View file

@ -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],