mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
3e2558a63a
commit
c07987138e
27 changed files with 63 additions and 4185 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { IsInt, IsPositive, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class FundGuildPoolDto {
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
amount: number;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { IsUUID, IsOptional } from 'class-validator';
|
||||
|
||||
export class PurchaseCreditsDto {
|
||||
@IsUUID()
|
||||
packageId: string;
|
||||
|
||||
@IsOptional()
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
),
|
||||
})
|
||||
);
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue