♻️ refactor(credits): simplify credit system by removing free credits and B2B

Remove free credits system (signup bonus, daily credits) and B2B organization
credits to simplify the codebase. Credits now only come from purchases or gifts.

Changes:
- Remove freeCreditsRemaining, dailyFreeCredits, lastDailyResetAt from balances
- Remove organizationBalances and creditAllocations tables from schema
- Simplify transaction types to: purchase, usage, refund, gift
- Remove B2B endpoints from credits controller
- Remove checkDailyReset, allocateCredits, deductCredits from service
- Add redeemPendingGifts method to auto-redeem gifts on registration
- Update frontend to remove free credits display
- Add database migration for the changes
- Update all related tests to match simplified system
This commit is contained in:
Till-JS 2026-02-16 11:54:32 +01:00
parent b9669c3ba5
commit bfc2737ce5
20 changed files with 272 additions and 2416 deletions

View file

@ -9,15 +9,13 @@ import { getManaAuthUrl } from './config';
// Types
export interface CreditBalance {
balance: number;
freeCreditsRemaining: number;
totalEarned: number;
totalSpent: number;
dailyFreeCredits: number;
}
export interface CreditTransaction {
id: string;
type: 'purchase' | 'usage' | 'refund' | 'bonus' | 'expiry' | 'adjustment';
type: 'purchase' | 'usage' | 'refund' | 'gift';
status: 'pending' | 'completed' | 'failed' | 'cancelled';
amount: number;
balanceBefore: number;

View file

@ -53,10 +53,6 @@
<span class="text-muted-foreground">{$_('dashboard.widgets.credits.available')}</span>
<span class="text-2xl font-bold">{formatCredits(data.balance)}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-muted-foreground">{$_('dashboard.widgets.credits.free_today')}</span>
<span class="font-medium">{data.freeCreditsRemaining}/{data.dailyFreeCredits}</span>
</div>
<a
href="/credits"
class="mt-4 block w-full rounded-lg bg-primary/10 py-2 text-center text-sm font-medium text-primary hover:bg-primary/20"

View file

@ -91,12 +91,8 @@
return '⚡';
case 'refund':
return '↩️';
case 'bonus':
case 'gift':
return '🎁';
case 'expiry':
return '⏰';
case 'adjustment':
return '🔧';
default:
return '📝';
}
@ -105,11 +101,10 @@
function getTransactionColor(type: string): string {
switch (type) {
case 'purchase':
case 'bonus':
case 'gift':
case 'refund':
return 'text-green-600 dark:text-green-400';
case 'usage':
case 'expiry':
return 'text-red-600 dark:text-red-400';
default:
return 'text-gray-600 dark:text-gray-400';
@ -123,7 +118,10 @@
// Redirect to Stripe Checkout
window.location.href = result.checkoutUrl;
} catch (e) {
showToast(e instanceof Error ? e.message : 'Fehler beim Erstellen der Checkout-Session', 'error');
showToast(
e instanceof Error ? e.message : 'Fehler beim Erstellen der Checkout-Session',
'error'
);
} finally {
processingPackageId = null;
}
@ -161,7 +159,7 @@
</Card>
{:else}
<!-- Balance Overview Cards -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-8">
<div class="grid gap-4 sm:grid-cols-3 mb-8">
<Card>
<div class="text-center">
<p class="text-sm text-muted-foreground">Verfügbare Credits</p>
@ -170,14 +168,6 @@
</p>
</div>
</Card>
<Card>
<div class="text-center">
<p class="text-sm text-muted-foreground">Gratis-Credits heute</p>
<p class="text-3xl font-bold text-green-600 dark:text-green-400 mt-1">
{balance?.freeCreditsRemaining ?? 0} / {balance?.dailyFreeCredits ?? 5}
</p>
</div>
</Card>
<Card>
<div class="text-center">
<p class="text-sm text-muted-foreground">Gesamt erhalten</p>
@ -281,11 +271,23 @@
</div>
{#if processingPackageId === pkg.id}
<svg class="animate-spin h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else}
<span class="font-semibold text-primary">{formatPrice(pkg.priceEuroCents)}</span>
<span class="font-semibold text-primary">{formatPrice(pkg.priceEuroCents)}</span
>
{/if}
</button>
{/each}
@ -362,8 +364,19 @@
>
{#if processingPackageId === pkg.id}
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Wird geladen...
{:else}
@ -388,7 +401,8 @@
<!-- Toast Notification -->
{#if toastMessage}
<div
class="fixed bottom-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg animate-fade-in {toastType === 'success'
class="fixed bottom-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg animate-fade-in {toastType ===
'success'
? 'bg-green-600 text-white'
: 'bg-red-600 text-white'}"
>

View file

@ -11,7 +11,6 @@ export interface CreditValidationResult {
export interface CreditBalance {
balance: number;
freeCreditsRemaining: number;
totalEarned: number;
totalSpent: number;
}
@ -58,11 +57,10 @@ export class CreditClientService {
): Promise<CreditValidationResult> {
try {
const balance = await this.getBalance(userId);
const totalAvailable = balance.balance + balance.freeCreditsRemaining;
return {
hasCredits: totalAvailable >= requiredAmount,
availableCredits: totalAvailable,
hasCredits: balance.balance >= requiredAmount,
availableCredits: balance.balance,
requiredCredits: requiredAmount,
};
} catch (error) {
@ -85,7 +83,6 @@ export class CreditClientService {
this.logger.warn('Service key not configured, returning default balance');
return {
balance: 1000,
freeCreditsRemaining: 100,
totalEarned: 0,
totalSpent: 0,
};
@ -108,13 +105,11 @@ export class CreditClientService {
const {
balance = 0,
freeCreditsRemaining = 0,
totalEarned = 0,
totalSpent = 0,
} = (await response.json()) as CreditBalance;
return {
balance,
freeCreditsRemaining,
totalEarned,
totalSpent,
};
@ -227,7 +222,6 @@ export class CreditClientService {
private getDefaultBalance(): CreditBalance {
return {
balance: 1000,
freeCreditsRemaining: 100,
totalEarned: 0,
totalSpent: 0,
};

View file

@ -72,14 +72,12 @@ export const mockPasswordFactory = {
/**
* Mock Balance Factory
* Simplified - no free credits or daily reset
*/
export const mockBalanceFactory = {
create: (userId: string, overrides: Partial<any> = {}) => ({
userId,
balance: 0,
freeCreditsRemaining: 150,
dailyFreeCredits: 5,
lastDailyResetAt: new Date(),
totalEarned: 0,
totalSpent: 0,
version: 0,
@ -88,10 +86,9 @@ export const mockBalanceFactory = {
...overrides,
}),
withBalance: (userId: string, balance: number, freeCredits = 0) => {
withBalance: (userId: string, balance: number) => {
return mockBalanceFactory.create(userId, {
balance,
freeCreditsRemaining: freeCredits,
});
},
};

View file

@ -18,8 +18,6 @@ export const createMockConfigService = (overrides: Record<string, any> = {}): Co
'jwt.refreshTokenExpiry': '7d',
'jwt.issuer': 'mana-core',
'jwt.audience': 'mana-universe',
'credits.signupBonus': 150,
'credits.dailyFreeCredits': 5,
'redis.host': 'localhost',
'redis.port': 6379,
'redis.password': 'test',

View file

@ -176,14 +176,12 @@ describe('BetterAuthService', () => {
},
});
// Verify personal credit balance was created
// Verify personal credit balance was created (no free credits)
expect(mockDb.insert).toHaveBeenCalled();
expect(mockDb.values).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-123',
balance: 0,
freeCreditsRemaining: 150,
dailyFreeCredits: 5,
totalEarned: 0,
totalSpent: 0,
})
@ -265,12 +263,10 @@ describe('BetterAuthService', () => {
await service.registerB2C(registerDto);
// Verify credit balance initialization
// Verify credit balance initialization (no free credits)
expect(mockDb.values).toHaveBeenCalledWith({
userId: 'user-123',
balance: 0,
freeCreditsRemaining: 150, // Signup bonus
dailyFreeCredits: 5,
totalEarned: 0,
totalSpent: 0,
});
@ -355,8 +351,8 @@ describe('BetterAuthService', () => {
},
});
// Verify both credit balances were created
expect(mockDb.insert).toHaveBeenCalledTimes(2);
// Verify personal credit balance was created (org balance removed)
expect(mockDb.insert).toHaveBeenCalledTimes(1);
// Verify response structure
expect(result).toEqual({
@ -366,7 +362,7 @@ describe('BetterAuthService', () => {
});
});
it('should create organization credit balance', async () => {
it('should create personal credit balance for org owner', async () => {
const registerDto = {
ownerEmail: 'owner@company.com',
password: 'SecurePassword123!',
@ -386,15 +382,13 @@ describe('BetterAuthService', () => {
await service.registerB2B(registerDto);
// Verify organization credit balance was created
// Verify personal credit balance was created (no org balance - B2B simplified)
expect(mockDb.values).toHaveBeenCalledWith(
expect.objectContaining({
organizationId: 'org-123',
userId: 'owner-123',
balance: 0,
allocatedCredits: 0,
availableCredits: 0,
totalPurchased: 0,
totalAllocated: 0,
totalEarned: 0,
totalSpent: 0,
})
);
});
@ -488,22 +482,14 @@ describe('BetterAuthService', () => {
await service.registerB2B(registerDto);
// Verify two credit balances were created
expect(mockDb.insert).toHaveBeenCalledTimes(2);
// Verify personal credit balance was created (no org balance)
expect(mockDb.insert).toHaveBeenCalledTimes(1);
// First call: organization balance
expect(mockDb.values).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
organizationId: 'org-123',
})
);
// Second call: personal balance
expect(mockDb.values).toHaveBeenNthCalledWith(
2,
// Personal balance for the owner
expect(mockDb.values).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'owner-123',
balance: 0,
})
);
});
@ -931,18 +917,16 @@ describe('BetterAuthService', () => {
await service.registerB2C(registerDto);
// Verify credit balance was initialized with correct values
// Verify credit balance was initialized with correct values (simplified - no free credits)
expect(mockDb.values).toHaveBeenCalledWith({
userId: 'user-123',
balance: 0,
freeCreditsRemaining: 150,
dailyFreeCredits: 5,
totalEarned: 0,
totalSpent: 0,
});
});
it('should initialize organization balance with zero credits', async () => {
it('should initialize personal credit balance for B2B owner', async () => {
const registerDto = {
ownerEmail: 'owner@company.com',
password: 'SecurePassword123!',
@ -959,15 +943,13 @@ describe('BetterAuthService', () => {
await service.registerB2B(registerDto);
// Verify organization balance was initialized
// Verify personal balance was initialized (no org balance - simplified system)
expect(mockDb.values).toHaveBeenCalledWith(
expect.objectContaining({
organizationId: 'org-123',
userId: 'owner-123',
balance: 0,
allocatedCredits: 0,
availableCredits: 0,
totalPurchased: 0,
totalAllocated: 0,
totalEarned: 0,
totalSpent: 0,
})
);
});

View file

@ -28,10 +28,11 @@ 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, organizationBalances } from '../../db/schema/credits.schema';
import { balances } from '../../db/schema/credits.schema';
import { ReferralCodeService } from '../../referrals/services/referral-code.service';
import { ReferralTierService } from '../../referrals/services/referral-tier.service';
import { ReferralTrackingService } from '../../referrals/services/referral-tracking.service';
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';
@ -120,6 +121,9 @@ export class BetterAuthService {
@Optional()
@Inject(forwardRef(() => ReferralTrackingService))
private referralTrackingService: ReferralTrackingService,
@Optional()
@Inject(forwardRef(() => GiftCodeService))
private giftCodeService: GiftCodeService,
loggerService: LoggerService
) {
this.logger = loggerService.setContext('BetterAuthService');
@ -163,6 +167,24 @@ export class BetterAuthService {
// Create personal credit balance
await this.createPersonalCreditBalance(user.id);
// Redeem any pending gift codes sent to this email
if (this.giftCodeService) {
try {
const giftResult = await this.giftCodeService.redeemPendingGifts(user.id, dto.email);
if (giftResult.redeemedCount > 0) {
this.logger.log('Redeemed pending gifts on registration', {
userId: user.id,
redeemedCount: giftResult.redeemedCount,
totalCredits: giftResult.totalCredits,
});
}
} catch (error) {
this.logger.warn('Failed to redeem pending gifts (non-critical)', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
// Initialize referral system for new user
await this.initializeUserReferrals(user.id, dto.referralCode, dto.sourceAppId);
@ -229,10 +251,7 @@ export class BetterAuthService {
const organizationId = orgResult.id;
// Step 3: Create organization credit balance
await this.createOrganizationCreditBalance(organizationId);
// Step 4: Create owner's personal balance (for when they use credits)
// Step 3: Create owner's personal balance (for when they use credits)
await this.createPersonalCreditBalance(ownerId);
return {
@ -1377,10 +1396,8 @@ export class BetterAuthService {
/**
* Create personal credit balance for user
*
* Initializes a user's credit balance with:
* - 0 purchased credits
* - 150 free signup credits
* - 5 daily free credits
* Initializes a user's credit balance with balance: 0
* Users must purchase credits or receive them as gifts.
*
* @param userId - User ID
* @private
@ -1392,8 +1409,6 @@ export class BetterAuthService {
await db.insert(balances).values({
userId: userId as any, // Cast to handle UUID type
balance: 0,
freeCreditsRemaining: 150, // Signup bonus
dailyFreeCredits: 5,
totalEarned: 0,
totalSpent: 0,
});
@ -1405,39 +1420,6 @@ export class BetterAuthService {
}
}
/**
* Create organization credit balance
*
* Initializes an organization's credit pool with:
* - 0 purchased credits
* - 0 allocated credits
* - 0 available credits
*
* The organization owner must purchase credits before allocating to employees.
*
* @param organizationId - Organization ID
* @private
*/
private async createOrganizationCreditBalance(organizationId: string) {
const db = getDb(this.databaseUrl);
try {
await db.insert(organizationBalances).values({
organizationId,
balance: 0,
allocatedCredits: 0,
availableCredits: 0,
totalPurchased: 0,
totalAllocated: 0,
});
} catch (error) {
this.logger.warn('Failed to create organization credit balance (non-critical)', {
error: error instanceof Error ? error.message : 'Unknown error',
});
// Don't throw - this is a non-critical operation
}
}
/**
* Helper function to create URL-safe slugs
*

View file

@ -103,6 +103,7 @@ describe('JwtAuthGuard', () => {
expect(result).toBe(true);
expect(mockRequest.user).toEqual({
sub: 'user-123',
userId: 'user-123',
email: 'test@example.com',
role: 'user',
@ -206,11 +207,12 @@ describe('JwtAuthGuard', () => {
await guard.canActivate(mockContext as any);
// Note: issuer defaults to http://localhost:3001 when BASE_URL and jwt.issuer are not set
expect(mockJwtVerify).toHaveBeenCalledWith(
'valid-jwt-token',
expect.anything(), // JWKS
expect.objectContaining({
issuer: 'manacore',
issuer: 'http://localhost:3001',
audience: 'manacore',
})
);
@ -240,6 +242,7 @@ describe('JwtAuthGuard', () => {
await guard.canActivate(mockContext as any);
expect(mockRequest.user).toEqual({
sub: 'user-456',
userId: 'user-456',
email: 'admin@example.com',
role: 'admin',
@ -396,9 +399,9 @@ describe('JwtAuthGuard', () => {
});
it('should use configured issuer and audience', async () => {
// Note: issuer = baseUrl || jwtIssuer || default, so we don't set BASE_URL to test jwt.issuer
const guardWithCustomConfig = new JwtAuthGuard(
createMockConfigService({
BASE_URL: 'http://localhost:3001',
'jwt.issuer': 'custom-issuer',
'jwt.audience': 'custom-audience',
}),

View file

@ -63,10 +63,7 @@ export default () => ({
limit: parseInt(env.RATE_LIMIT_MAX || '100', 10),
},
credits: {
signupBonus: parseInt(env.CREDITS_SIGNUP_BONUS || '150', 10),
dailyFreeCredits: parseInt(env.CREDITS_DAILY_FREE || '5', 10),
},
// Credits config removed - no free credits system
ai: {
geminiApiKey: env.GOOGLE_GENAI_API_KEY || '',

View file

@ -52,10 +52,6 @@ const envSchema = z.object({
RATE_LIMIT_TTL: z.string().regex(/^\d+$/).optional(),
RATE_LIMIT_MAX: z.string().regex(/^\d+$/).optional(),
// Credits
CREDITS_SIGNUP_BONUS: z.string().regex(/^\d+$/).optional(),
CREDITS_DAILY_FREE: z.string().regex(/^\d+$/).optional(),
// AI
GOOGLE_GENAI_API_KEY: z.string().optional(),

View file

@ -98,7 +98,7 @@ describe('CreditsController', () => {
describe('GET /credits/balance', () => {
it('should return user balance', async () => {
const expectedBalance = mockBalanceFactory.withBalance(mockUser.userId, 500, 100);
const expectedBalance = mockBalanceFactory.withBalance(mockUser.userId, 500);
creditsService.getBalance.mockResolvedValue(expectedBalance);
@ -111,7 +111,6 @@ describe('CreditsController', () => {
it('should return zero balance for new user', async () => {
const newUserBalance = mockBalanceFactory.create(mockUser.userId, {
balance: 0,
freeCreditsRemaining: 150,
});
creditsService.getBalance.mockResolvedValue(newUserBalance);
@ -119,21 +118,6 @@ describe('CreditsController', () => {
const result = await controller.getBalance(mockUser);
expect(result.balance).toBe(0);
expect(result.freeCreditsRemaining).toBe(150);
});
it('should handle balance with daily free credits', async () => {
const balanceWithDailyCredits = mockBalanceFactory.create(mockUser.userId, {
balance: 100,
freeCreditsRemaining: 50,
dailyFreeCredits: 5,
});
creditsService.getBalance.mockResolvedValue(balanceWithDailyCredits);
const result = await controller.getBalance(mockUser);
expect(result.dailyFreeCredits).toBe(5);
});
});
@ -362,404 +346,5 @@ describe('CreditsController', () => {
});
});
// ============================================================================
// B2B ENDPOINTS - Organization Credits
// ============================================================================
describe('B2B Endpoints', () => {
const organizationId = 'org-123';
const employeeId = 'emp-789';
// --------------------------------------------------------------------------
// POST /credits/organization/allocate
// --------------------------------------------------------------------------
describe('POST /credits/organization/allocate', () => {
it('should successfully allocate credits to employee', async () => {
const allocateDto = {
organizationId,
employeeId,
amount: 100,
reason: 'Monthly allocation',
};
const expectedResult = {
success: true,
allocation: {
id: 'alloc-123',
organizationId,
employeeId,
amount: 100,
allocatedBy: mockOrgOwner.userId,
},
newOrgBalance: 900,
newEmployeeBalance: 100,
};
creditsService.allocateCredits.mockResolvedValue(expectedResult as any);
const result = await controller.allocateCredits(mockOrgOwner, allocateDto);
expect(result).toEqual(expectedResult);
expect(creditsService.allocateCredits).toHaveBeenCalledWith(
mockOrgOwner.userId,
allocateDto
);
});
it('should propagate ForbiddenException for non-owners', async () => {
const allocateDto = {
organizationId,
employeeId,
amount: 50,
};
creditsService.allocateCredits.mockRejectedValue(
new ForbiddenException('Only organization owners can allocate credits')
);
await expect(controller.allocateCredits(mockUser, allocateDto)).rejects.toThrow(
ForbiddenException
);
});
it('should propagate BadRequestException for insufficient org credits', async () => {
const allocateDto = {
organizationId,
employeeId,
amount: 10000,
};
creditsService.allocateCredits.mockRejectedValue(
new BadRequestException('Insufficient organization credits')
);
await expect(controller.allocateCredits(mockOrgOwner, allocateDto)).rejects.toThrow(
BadRequestException
);
});
it('should pass optional reason parameter', async () => {
const allocateDto = {
organizationId,
employeeId,
amount: 200,
reason: 'Bonus for project completion',
};
creditsService.allocateCredits.mockResolvedValue({ success: true } as any);
await controller.allocateCredits(mockOrgOwner, allocateDto);
expect(creditsService.allocateCredits).toHaveBeenCalledWith(
mockOrgOwner.userId,
expect.objectContaining({ reason: 'Bonus for project completion' })
);
});
});
// --------------------------------------------------------------------------
// GET /credits/organization/:organizationId/balance
// --------------------------------------------------------------------------
describe('GET /credits/organization/:organizationId/balance', () => {
it('should return organization balance', async () => {
const expectedBalance = mockOrganizationBalanceFactory.withBalance(
organizationId,
1000,
300
);
creditsService.getOrganizationBalance.mockResolvedValue(expectedBalance as any);
const result = await controller.getOrganizationBalance(organizationId);
expect(result).toEqual(expectedBalance);
expect(creditsService.getOrganizationBalance).toHaveBeenCalledWith(organizationId);
});
it('should return balance breakdown with allocations', async () => {
const orgBalance = mockOrganizationBalanceFactory.create(organizationId, {
balance: 5000,
allocatedCredits: 2000,
availableCredits: 3000,
totalPurchased: 6000,
totalAllocated: 3500,
});
creditsService.getOrganizationBalance.mockResolvedValue(orgBalance as any);
const result = await controller.getOrganizationBalance(organizationId);
expect(result.balance).toBe(5000);
expect(result.allocatedCredits).toBe(2000);
expect(result.availableCredits).toBe(3000);
});
it('should propagate NotFoundException for non-existent org', async () => {
creditsService.getOrganizationBalance.mockRejectedValue(
new NotFoundException('Organization not found')
);
await expect(controller.getOrganizationBalance('non-existent-org')).rejects.toThrow(
NotFoundException
);
});
});
// --------------------------------------------------------------------------
// GET /credits/organization/:organizationId/employee/:employeeId/balance
// --------------------------------------------------------------------------
describe('GET /credits/organization/:organizationId/employee/:employeeId/balance', () => {
it('should return employee balance within organization', async () => {
const expectedBalance = {
employeeId,
organizationId,
balance: 250,
allocatedTotal: 500,
usedTotal: 250,
};
creditsService.getEmployeeCreditBalance.mockResolvedValue(expectedBalance as any);
const result = await controller.getEmployeeBalance(organizationId, employeeId);
expect(result).toEqual(expectedBalance);
expect(creditsService.getEmployeeCreditBalance).toHaveBeenCalledWith(
employeeId,
organizationId
);
});
it('should return zero for employee with no allocations', async () => {
const zeroBalance = {
employeeId,
organizationId,
balance: 0,
allocatedTotal: 0,
usedTotal: 0,
};
creditsService.getEmployeeCreditBalance.mockResolvedValue(zeroBalance as any);
const result = await controller.getEmployeeBalance(organizationId, employeeId);
expect(result!.balance).toBe(0);
});
it('should propagate NotFoundException for non-existent employee', async () => {
creditsService.getEmployeeCreditBalance.mockRejectedValue(
new NotFoundException('Employee not found in organization')
);
await expect(
controller.getEmployeeBalance(organizationId, 'non-existent-emp')
).rejects.toThrow(NotFoundException);
});
});
// --------------------------------------------------------------------------
// POST /credits/organization/:organizationId/use
// --------------------------------------------------------------------------
describe('POST /credits/organization/:organizationId/use', () => {
it('should deduct credits with organization tracking', async () => {
const useCreditsDto = mockDtoFactory.useCredits({
amount: 15,
appId: 'chat',
description: 'Team chat usage',
});
const expectedResult = {
success: true,
transaction: mockTransactionFactory.create(mockUser.userId, {
amount: -15,
organizationId,
}),
newBalance: 85,
};
creditsService.deductCredits.mockResolvedValue(expectedResult as any);
const result = await controller.deductCreditsWithOrgTracking(
mockUser,
organizationId,
useCreditsDto
);
expect(result).toEqual(expectedResult);
expect(creditsService.deductCredits).toHaveBeenCalledWith(
mockUser.userId,
useCreditsDto,
organizationId
);
});
it('should track organization ID in transaction', async () => {
const useCreditsDto = mockDtoFactory.useCredits({
amount: 20,
appId: 'picture',
description: 'Image generation for team',
});
creditsService.deductCredits.mockResolvedValue({ success: true } as any);
await controller.deductCreditsWithOrgTracking(mockUser, organizationId, useCreditsDto);
expect(creditsService.deductCredits).toHaveBeenCalledWith(
mockUser.userId,
useCreditsDto,
organizationId
);
});
it('should propagate BadRequestException for insufficient employee credits', async () => {
const useCreditsDto = mockDtoFactory.useCredits({
amount: 500,
appId: 'wisekeep',
description: 'Video analysis',
});
creditsService.deductCredits.mockRejectedValue(
new BadRequestException('Insufficient credits')
);
await expect(
controller.deductCreditsWithOrgTracking(mockUser, organizationId, useCreditsDto)
).rejects.toThrow(BadRequestException);
});
it('should handle idempotency for organization credit usage', async () => {
const idempotencyKey = `org-usage-${nanoid()}`;
const useCreditsDto = mockDtoFactory.useCredits({
amount: 30,
appId: 'memoro',
description: 'Voice transcription',
idempotencyKey,
});
creditsService.deductCredits.mockResolvedValue({ success: true } as any);
await controller.deductCreditsWithOrgTracking(mockUser, organizationId, useCreditsDto);
expect(creditsService.deductCredits).toHaveBeenCalledWith(
mockUser.userId,
expect.objectContaining({ idempotencyKey }),
organizationId
);
});
});
});
// ============================================================================
// Guard Tests
// ============================================================================
describe('Guards', () => {
it('should have JwtAuthGuard applied at class level', async () => {
const guards = Reflect.getMetadata('__guards__', CreditsController);
expect(guards).toBeDefined();
expect(guards).toContain(JwtAuthGuard);
});
it('should require authentication for all endpoints', () => {
// All credits endpoints require authentication
// This is handled at the class level with @UseGuards(JwtAuthGuard)
const classGuards = Reflect.getMetadata('__guards__', CreditsController);
expect(classGuards).toContain(JwtAuthGuard);
});
});
// ============================================================================
// Error Handling
// ============================================================================
describe('Error Handling', () => {
it('should propagate service errors correctly', async () => {
const error = new Error('Database connection failed');
creditsService.getBalance.mockRejectedValue(error);
await expect(controller.getBalance(mockUser)).rejects.toThrow('Database connection failed');
});
it('should handle concurrent request errors', async () => {
const useCreditsDto = mockDtoFactory.useCredits({ amount: 10 });
creditsService.useCredits.mockRejectedValue(
new BadRequestException('Concurrent modification detected, please retry')
);
await expect(controller.useCredits(mockUser, useCreditsDto)).rejects.toThrow(
BadRequestException
);
});
it('should handle validation errors in allocation', async () => {
const invalidDto = {
organizationId: '',
employeeId: 'emp-123',
amount: -100, // Invalid negative amount
};
creditsService.allocateCredits.mockRejectedValue(
new BadRequestException('Amount must be positive')
);
await expect(controller.allocateCredits(mockOrgOwner, invalidDto)).rejects.toThrow(
BadRequestException
);
});
});
// ============================================================================
// Edge Cases
// ============================================================================
describe('Edge Cases', () => {
it('should handle zero credit usage', async () => {
const useCreditsDto = mockDtoFactory.useCredits({ amount: 0 });
creditsService.useCredits.mockRejectedValue(
new BadRequestException('Amount must be greater than zero')
);
await expect(controller.useCredits(mockUser, useCreditsDto)).rejects.toThrow(
BadRequestException
);
});
it('should handle very large credit amounts', async () => {
const useCreditsDto = mockDtoFactory.useCredits({
amount: 999999999,
appId: 'test',
description: 'Large transaction',
});
creditsService.useCredits.mockRejectedValue(new BadRequestException('Amount exceeds limit'));
await expect(controller.useCredits(mockUser, useCreditsDto)).rejects.toThrow(
BadRequestException
);
});
it('should handle special characters in description', async () => {
const useCreditsDto = mockDtoFactory.useCredits({
amount: 5,
appId: 'chat',
description: 'Test with émojis 🎉 and "quotes"',
});
creditsService.useCredits.mockResolvedValue({ success: true } as any);
await controller.useCredits(mockUser, useCreditsDto);
expect(creditsService.useCredits).toHaveBeenCalledWith(
mockUser.userId,
expect.objectContaining({
description: 'Test with émojis 🎉 and "quotes"',
})
);
});
});
// B2B endpoints removed - functionality simplified to B2C only
});

View file

@ -5,7 +5,6 @@ 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 { AllocateCreditsDto } from './dto/allocate-credits.dto';
import { PurchaseCreditsDto } from './dto/purchase-credits.dto';
import { CreatePaymentLinkDto } from './dto/create-payment-link.dto';
@ -97,61 +96,11 @@ export class CreditsController {
},
})
@ApiResponse({ status: 404, description: 'Package not found' })
async createPaymentLink(
@CurrentUser() user: CurrentUserData,
@Body() dto: CreatePaymentLinkDto
) {
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,
});
}
// ============================================================================
// ORGANIZATION / B2B ENDPOINTS
// ============================================================================
/**
* Allocate credits from organization to employee
* Only organization owners can allocate credits
*/
@Post('organization/allocate')
async allocateCredits(
@CurrentUser() user: CurrentUserData,
@Body() allocateDto: AllocateCreditsDto
) {
return this.creditsService.allocateCredits(user.userId, allocateDto);
}
/**
* Get organization credit balance and allocation stats
*/
@Get('organization/:organizationId/balance')
async getOrganizationBalance(@Param('organizationId') organizationId: string) {
return this.creditsService.getOrganizationBalance(organizationId);
}
/**
* Get employee's credit balance within an organization context
*/
@Get('organization/:organizationId/employee/:employeeId/balance')
async getEmployeeBalance(
@Param('organizationId') organizationId: string,
@Param('employeeId') employeeId: string
) {
return this.creditsService.getEmployeeCreditBalance(employeeId, organizationId);
}
/**
* Deduct credits with organization tracking (for B2B usage)
*/
@Post('organization/:organizationId/use')
async deductCreditsWithOrgTracking(
@CurrentUser() user: CurrentUserData,
@Param('organizationId') organizationId: string,
@Body() useCreditsDto: UseCreditsDto
) {
return this.creditsService.deductCredits(user.userId, useCreditsDto, organizationId);
}
}

File diff suppressed because it is too large Load diff

View file

@ -3,28 +3,15 @@ import {
BadRequestException,
NotFoundException,
ConflictException,
ForbiddenException,
Inject,
forwardRef,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, and, sql, desc, sum } from 'drizzle-orm';
import { eq, and, desc } from 'drizzle-orm';
import { getDb } from '../db/connection';
import {
balances,
transactions,
purchases,
packages,
usageStats,
organizationBalances,
creditAllocations,
members,
organizations,
users,
} from '../db/schema';
import { balances, transactions, purchases, packages, usageStats, users } from '../db/schema';
import { UseCreditsDto } from './dto/use-credits.dto';
import { AllocateCreditsDto } from './dto/allocate-credits.dto';
import { StripeService } from '../stripe/stripe.service';
@Injectable()
@ -44,8 +31,6 @@ export class CreditsService {
async initializeUserBalance(userId: string) {
const db = this.getDb();
const signupBonus = this.configService.get<number>('credits.signupBonus') || 150;
const dailyFreeCredits = this.configService.get<number>('credits.dailyFreeCredits') || 5;
// Check if balance already exists
const [existingBalance] = await db
@ -58,40 +43,23 @@ export class CreditsService {
return existingBalance;
}
// Create initial balance with signup bonus
// Create initial balance (starts at 0 - no signup bonus)
const [balance] = await db
.insert(balances)
.values({
userId,
balance: 0,
freeCreditsRemaining: signupBonus,
dailyFreeCredits,
lastDailyResetAt: new Date(),
totalEarned: 0,
totalSpent: 0,
})
.returning();
// Create transaction record for signup bonus
await db.insert(transactions).values({
userId,
type: 'bonus',
status: 'completed',
amount: signupBonus,
balanceBefore: 0,
balanceAfter: 0,
appId: 'system',
description: 'Signup bonus',
completedAt: new Date(),
});
return balance;
}
async getBalance(userId: string) {
const db = this.getDb();
// Check and apply daily free credits reset
await this.checkDailyReset(userId);
const [balance] = await db.select().from(balances).where(eq(balances.userId, userId)).limit(1);
if (!balance) {
@ -101,10 +69,8 @@ export class CreditsService {
return {
balance: balance.balance,
freeCreditsRemaining: balance.freeCreditsRemaining,
totalEarned: balance.totalEarned,
totalSpent: balance.totalSpent,
dailyFreeCredits: balance.dailyFreeCredits,
};
}
@ -142,18 +108,11 @@ export class CreditsService {
throw new NotFoundException('User balance not found');
}
const totalAvailable = currentBalance.balance + currentBalance.freeCreditsRemaining;
if (totalAvailable < useCreditsDto.amount) {
if (currentBalance.balance < useCreditsDto.amount) {
throw new BadRequestException('Insufficient credits');
}
// Calculate deduction from free and paid credits
const freeCreditsUsed = Math.min(useCreditsDto.amount, currentBalance.freeCreditsRemaining);
const paidCreditsUsed = useCreditsDto.amount - freeCreditsUsed;
const newFreeCredits = currentBalance.freeCreditsRemaining - freeCreditsUsed;
const newBalance = currentBalance.balance - paidCreditsUsed;
const newBalance = currentBalance.balance - useCreditsDto.amount;
const newTotalSpent = currentBalance.totalSpent + useCreditsDto.amount;
// Update balance with optimistic locking
@ -161,7 +120,6 @@ export class CreditsService {
.update(balances)
.set({
balance: newBalance,
freeCreditsRemaining: newFreeCredits,
totalSpent: newTotalSpent,
version: currentBalance.version + 1,
updatedAt: new Date(),
@ -181,8 +139,8 @@ export class CreditsService {
type: 'usage',
status: 'completed',
amount: -useCreditsDto.amount,
balanceBefore: currentBalance.balance + currentBalance.freeCreditsRemaining,
balanceAfter: newBalance + newFreeCredits,
balanceBefore: currentBalance.balance,
balanceAfter: newBalance,
appId: useCreditsDto.appId,
description: useCreditsDto.description,
metadata: useCreditsDto.metadata,
@ -208,7 +166,6 @@ export class CreditsService {
transaction,
newBalance: {
balance: newBalance,
freeCreditsRemaining: newFreeCredits,
totalSpent: newTotalSpent,
},
};
@ -249,88 +206,6 @@ export class CreditsService {
.orderBy(packages.sortOrder);
}
private async checkDailyReset(userId: string) {
const db = this.getDb();
const [balance] = await db.select().from(balances).where(eq(balances.userId, userId)).limit(1);
if (!balance) {
return;
}
const now = new Date();
const lastReset = balance.lastDailyResetAt;
// Check if last reset was on a different day
if (
!lastReset ||
lastReset.getDate() !== now.getDate() ||
lastReset.getMonth() !== now.getMonth() ||
lastReset.getFullYear() !== now.getFullYear()
) {
// Reset daily free credits
await db
.update(balances)
.set({
freeCreditsRemaining: balance.freeCreditsRemaining + balance.dailyFreeCredits,
lastDailyResetAt: now,
updatedAt: now,
})
.where(eq(balances.userId, userId));
// Create transaction record for daily bonus
await db.insert(transactions).values({
userId,
type: 'bonus',
status: 'completed',
amount: balance.dailyFreeCredits,
balanceBefore: balance.balance + balance.freeCreditsRemaining,
balanceAfter: balance.balance + balance.freeCreditsRemaining + balance.dailyFreeCredits,
appId: 'system',
description: 'Daily free credits',
completedAt: now,
});
}
}
// ============================================================================
// ORGANIZATION CREDIT METHODS (B2B)
// ============================================================================
/**
* Create organization credit balance
* Called when a new organization is created
*/
async createOrganizationCreditBalance(organizationId: string) {
const db = this.getDb();
// Check if balance already exists
const [existingBalance] = await db
.select()
.from(organizationBalances)
.where(eq(organizationBalances.organizationId, organizationId))
.limit(1);
if (existingBalance) {
return existingBalance;
}
// Create initial balance
const [balance] = await db
.insert(organizationBalances)
.values({
organizationId,
balance: 0,
allocatedCredits: 0,
availableCredits: 0,
totalPurchased: 0,
totalAllocated: 0,
})
.returning();
return balance;
}
/**
* Create personal credit balance (B2C user)
* Alias for initializeUserBalance for clarity
@ -339,341 +214,6 @@ export class CreditsService {
return this.initializeUserBalance(userId);
}
/**
* Allocate credits from organization to employee
* Only organization owners can allocate credits
*/
async allocateCredits(allocatorUserId: string, allocateDto: AllocateCreditsDto) {
const db = this.getDb();
const { organizationId, employeeId, amount, reason } = allocateDto;
return await db.transaction(async (tx) => {
// 1. Verify allocator has 'owner' role in the organization
const [member] = await tx
.select()
.from(members)
.where(and(eq(members.organizationId, organizationId), eq(members.userId, allocatorUserId)))
.limit(1);
if (!member || member.role !== 'owner') {
throw new ForbiddenException('Only organization owners can allocate credits');
}
// 2. Get organization balance with row lock
const [orgBalance] = await tx
.select()
.from(organizationBalances)
.where(eq(organizationBalances.organizationId, organizationId))
.for('update')
.limit(1);
if (!orgBalance) {
throw new NotFoundException('Organization balance not found');
}
// 3. Check if organization has sufficient available credits
if (orgBalance.availableCredits < amount) {
throw new BadRequestException(
`Insufficient organization credits. Available: ${orgBalance.availableCredits}, Requested: ${amount}`
);
}
// 4. Get or create employee balance with row lock
let employeeBalance = await tx
.select()
.from(balances)
.where(eq(balances.userId, employeeId))
.for('update')
.limit(1)
.then((rows) => rows[0]);
if (!employeeBalance) {
// Initialize employee balance within the transaction
const signupBonus = this.configService.get<number>('credits.signupBonus') || 150;
const dailyFreeCredits = this.configService.get<number>('credits.dailyFreeCredits') || 5;
const [newBalance] = await tx
.insert(balances)
.values({
userId: employeeId,
balance: 0,
freeCreditsRemaining: signupBonus,
dailyFreeCredits,
lastDailyResetAt: new Date(),
})
.returning();
employeeBalance = newBalance;
}
const currentEmployeeBalance = employeeBalance.balance;
const newEmployeeBalance = currentEmployeeBalance + amount;
// 5. Update organization balance
const newAllocatedCredits = orgBalance.allocatedCredits + amount;
const newAvailableCredits = orgBalance.balance - newAllocatedCredits;
const updateOrgResult = await tx
.update(organizationBalances)
.set({
allocatedCredits: newAllocatedCredits,
availableCredits: newAvailableCredits,
totalAllocated: orgBalance.totalAllocated + amount,
version: orgBalance.version + 1,
updatedAt: new Date(),
})
.where(
and(
eq(organizationBalances.organizationId, organizationId),
eq(organizationBalances.version, orgBalance.version)
)
)
.returning();
if (updateOrgResult.length === 0) {
throw new ConflictException(
'Organization balance was modified by another transaction. Please retry.'
);
}
// 6. Update employee balance
const updateEmployeeResult = await tx
.update(balances)
.set({
balance: newEmployeeBalance,
totalEarned: employeeBalance.totalEarned + amount,
version: employeeBalance.version + 1,
updatedAt: new Date(),
})
.where(and(eq(balances.userId, employeeId), eq(balances.version, employeeBalance.version)))
.returning();
if (updateEmployeeResult.length === 0) {
throw new ConflictException(
'Employee balance was modified by another transaction. Please retry.'
);
}
// 7. Create allocation record (audit trail)
const [allocation] = await tx
.insert(creditAllocations)
.values({
organizationId,
employeeId,
amount,
allocatedBy: allocatorUserId,
reason: reason || 'Credit allocation',
balanceBefore: currentEmployeeBalance,
balanceAfter: newEmployeeBalance,
})
.returning();
// 8. Create transaction record for employee
await tx.insert(transactions).values({
userId: employeeId,
type: 'bonus',
status: 'completed',
amount,
balanceBefore: currentEmployeeBalance,
balanceAfter: newEmployeeBalance,
appId: 'organization',
description: `Credit allocation from organization: ${reason || 'N/A'}`,
organizationId,
completedAt: new Date(),
});
return {
success: true,
allocation,
organizationBalance: {
balance: orgBalance.balance,
allocatedCredits: newAllocatedCredits,
availableCredits: newAvailableCredits,
},
employeeBalance: {
balance: newEmployeeBalance,
},
};
});
}
/**
* Get employee's credit balance (allocated from organization)
* Returns the employee's personal balance
*/
async getEmployeeCreditBalance(userId: string, organizationId?: string) {
const db = this.getDb();
// Get employee's personal balance
const [balance] = await db.select().from(balances).where(eq(balances.userId, userId)).limit(1);
if (!balance) {
return null;
}
return {
balance: balance.balance,
freeCreditsRemaining: balance.freeCreditsRemaining,
totalEarned: balance.totalEarned,
totalSpent: balance.totalSpent,
};
}
/**
* Get personal credit balance (B2C user)
* Alias for getBalance for clarity
*/
async getPersonalCreditBalance(userId: string) {
return this.getBalance(userId);
}
/**
* Get organization balance and allocation statistics
*/
async getOrganizationBalance(organizationId: string) {
const db = this.getDb();
// Get organization balance
const [orgBalance] = await db
.select()
.from(organizationBalances)
.where(eq(organizationBalances.organizationId, organizationId))
.limit(1);
if (!orgBalance) {
throw new NotFoundException('Organization balance not found');
}
// Get allocation statistics
const allocations = await db
.select()
.from(creditAllocations)
.where(eq(creditAllocations.organizationId, organizationId))
.orderBy(desc(creditAllocations.createdAt))
.limit(10); // Last 10 allocations
return {
balance: orgBalance.balance,
allocatedCredits: orgBalance.allocatedCredits,
availableCredits: orgBalance.availableCredits,
totalPurchased: orgBalance.totalPurchased,
totalAllocated: orgBalance.totalAllocated,
recentAllocations: allocations,
};
}
/**
* Deduct credits with organization tracking
* Enhanced version of useCredits that tracks organization_id for B2B users
*/
async deductCredits(userId: string, useCreditsDto: UseCreditsDto, organizationId?: string) {
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
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');
}
const totalAvailable = currentBalance.balance + currentBalance.freeCreditsRemaining;
if (totalAvailable < useCreditsDto.amount) {
throw new BadRequestException('Insufficient credits');
}
// Calculate deduction from free and paid credits
const freeCreditsUsed = Math.min(useCreditsDto.amount, currentBalance.freeCreditsRemaining);
const paidCreditsUsed = useCreditsDto.amount - freeCreditsUsed;
const newFreeCredits = currentBalance.freeCreditsRemaining - freeCreditsUsed;
const newBalance = currentBalance.balance - paidCreditsUsed;
const newTotalSpent = currentBalance.totalSpent + useCreditsDto.amount;
// Update balance
const updateResult = await tx
.update(balances)
.set({
balance: newBalance,
freeCreditsRemaining: newFreeCredits,
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 with organization_id
const [transaction] = await tx
.insert(transactions)
.values({
userId,
type: 'usage',
status: 'completed',
amount: -useCreditsDto.amount,
balanceBefore: currentBalance.balance + currentBalance.freeCreditsRemaining,
balanceAfter: newBalance + newFreeCredits,
appId: useCreditsDto.appId,
description: useCreditsDto.description,
organizationId: organizationId || null, // Track organization for B2B
metadata: useCreditsDto.metadata,
idempotencyKey: useCreditsDto.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: useCreditsDto.appId,
creditsUsed: useCreditsDto.amount,
date: today,
metadata: useCreditsDto.metadata,
});
return {
success: true,
transaction,
newBalance: {
balance: newBalance,
freeCreditsRemaining: newFreeCredits,
totalSpent: newTotalSpent,
},
};
});
}
// ============================================================================
// STRIPE PURCHASE METHODS
// ============================================================================
@ -797,18 +337,14 @@ export class CreditsService {
.limit(1);
if (!balance) {
// Initialize balance if not exists
const signupBonus = this.configService.get<number>('credits.signupBonus') || 150;
const dailyFreeCredits = this.configService.get<number>('credits.dailyFreeCredits') || 5;
// Initialize balance if not exists (starts at 0)
[balance] = await tx
.insert(balances)
.values({
userId: purchase.userId,
balance: 0,
freeCreditsRemaining: signupBonus,
dailyFreeCredits,
lastDailyResetAt: new Date(),
totalEarned: 0,
totalSpent: 0,
})
.returning();
}
@ -1024,7 +560,8 @@ export class CreditsService {
// 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 successUrl =
options?.successUrl || `${baseUrl}/credits/success?purchase_id=${purchase.id}`;
const cancelUrl = options?.cancelUrl || `${baseUrl}/credits/cancelled`;
// 6. Create Checkout Session

View file

@ -1,17 +0,0 @@
import { IsUUID, IsInt, IsString, IsOptional, Min } from 'class-validator';
export class AllocateCreditsDto {
@IsString()
organizationId: string;
@IsUUID()
employeeId: string;
@IsInt()
@Min(1)
amount: number;
@IsString()
@IsOptional()
reason?: string;
}

View file

@ -0,0 +1,40 @@
-- Migration: Simplify Credits System
-- Date: 2026-02-16
-- Description: Remove free credits and B2B organization credits
--
-- This migration:
-- 1. Migrates existing free credits to balance (one-time)
-- 2. Drops B2B organization credit tables
-- 3. Removes free credit columns from balances table
--
-- IMPORTANT: Run this migration during low-traffic period as it modifies the balances table
-- Step 1: Migrate free credits to balance (one-time conversion)
-- Any existing free_credits_remaining are added to the main balance
UPDATE credits.balances
SET balance = balance + free_credits_remaining
WHERE free_credits_remaining > 0;
-- Step 2: Drop B2B organization credit tables (if they exist)
DROP TABLE IF EXISTS credits.credit_allocations CASCADE;
DROP TABLE IF EXISTS credits.organization_balances CASCADE;
-- Step 3: Remove organization_id from transactions (if column exists)
ALTER TABLE credits.transactions
DROP COLUMN IF EXISTS organization_id;
-- Step 4: Remove free credit columns from balances table
ALTER TABLE credits.balances
DROP COLUMN IF EXISTS free_credits_remaining,
DROP COLUMN IF EXISTS daily_free_credits,
DROP COLUMN IF EXISTS last_daily_reset_at;
-- Step 5: Drop old transaction type values (Note: PostgreSQL doesn't support direct enum value removal)
-- The old values (bonus, expiry, adjustment, gift_reserve, gift_release, gift_receive)
-- remain in the enum for backward compatibility with historical data.
-- New transactions will only use: purchase, usage, refund, gift
-- Verification queries (run manually to confirm migration):
-- SELECT COUNT(*) FROM credits.balances WHERE free_credits_remaining IS NOT NULL; -- Should be 0 after migration
-- SELECT column_name FROM information_schema.columns WHERE table_schema = 'credits' AND table_name = 'balances';
-- SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'credits' AND table_name = 'organization_balances'; -- Should be 0

View file

@ -10,21 +10,16 @@ import {
boolean,
} from 'drizzle-orm/pg-core';
import { users } from './auth.schema';
import { organizations } from './organizations.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',
'bonus',
'expiry',
'adjustment',
'gift_reserve',
'gift_release',
'gift_receive',
'gift',
]);
// Transaction status enum
@ -47,14 +42,12 @@ export const stripeCustomers = creditsSchema.table('stripe_customers', {
});
// 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(),
freeCreditsRemaining: integer('free_credits_remaining').default(150).notNull(),
dailyFreeCredits: integer('daily_free_credits').default(5).notNull(),
lastDailyResetAt: timestamp('last_daily_reset_at', { withTimezone: true }).defaultNow(),
totalEarned: integer('total_earned').default(0).notNull(),
totalSpent: integer('total_spent').default(0).notNull(),
version: integer('version').default(0).notNull(),
@ -77,7 +70,6 @@ export const transactions = creditsSchema.table(
balanceAfter: integer('balance_after').notNull(),
appId: text('app_id').notNull(),
description: text('description').notNull(),
organizationId: text('organization_id').references(() => organizations.id),
metadata: jsonb('metadata'),
idempotencyKey: text('idempotency_key').unique(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
@ -86,7 +78,6 @@ export const transactions = creditsSchema.table(
(table) => ({
userIdIdx: index('transactions_user_id_idx').on(table.userId),
appIdIdx: index('transactions_app_id_idx').on(table.appId),
organizationIdIdx: index('transactions_organization_id_idx').on(table.organizationId),
createdAtIdx: index('transactions_created_at_idx').on(table.createdAt),
idempotencyKeyIdx: index('transactions_idempotency_key_idx').on(table.idempotencyKey),
})
@ -152,46 +143,4 @@ export const usageStats = creditsSchema.table(
})
);
// Organization credit balances (B2B)
export const organizationBalances = creditsSchema.table('organization_balances', {
organizationId: text('organization_id')
.primaryKey()
.references(() => organizations.id, { onDelete: 'cascade' }),
balance: integer('balance').default(0).notNull(),
allocatedCredits: integer('allocated_credits').default(0).notNull(),
availableCredits: integer('available_credits').default(0).notNull(),
totalPurchased: integer('total_purchased').default(0).notNull(),
totalAllocated: integer('total_allocated').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(),
});
// Credit allocations (B2B - tracking allocations from org to employees)
export const creditAllocations = creditsSchema.table(
'credit_allocations',
{
id: uuid('id').primaryKey().defaultRandom(),
organizationId: text('organization_id')
.references(() => organizations.id, { onDelete: 'cascade' })
.notNull(),
employeeId: text('employee_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
amount: integer('amount').notNull(),
allocatedBy: text('allocated_by')
.references(() => users.id)
.notNull(),
reason: text('reason'),
balanceBefore: integer('balance_before').notNull(),
balanceAfter: integer('balance_after').notNull(),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
organizationIdIdx: index('credit_allocations_organization_id_idx').on(table.organizationId),
employeeIdIdx: index('credit_allocations_employee_id_idx').on(table.employeeId),
allocatedByIdx: index('credit_allocations_allocated_by_idx').on(table.allocatedBy),
createdAtIdx: index('credit_allocations_created_at_idx').on(table.createdAt),
})
);
// B2B organization credit tables removed - simplified to B2C only

View file

@ -138,10 +138,9 @@ export class GiftCodeService {
throw new NotFoundException('User balance not found');
}
const totalAvailable = userBalance.balance + userBalance.freeCreditsRemaining;
if (totalAvailable < totalCredits) {
if (userBalance.balance < totalCredits) {
throw new BadRequestException(
`Insufficient credits. Required: ${totalCredits}, Available: ${totalAvailable}`
`Insufficient credits. Required: ${totalCredits}, Available: ${userBalance.balance}`
);
}
@ -149,17 +148,12 @@ export class GiftCodeService {
const code = await this.generateUniqueCode();
// 3. Deduct credits from user (reserve them)
const freeCreditsUsed = Math.min(totalCredits, userBalance.freeCreditsRemaining);
const paidCreditsUsed = totalCredits - freeCreditsUsed;
const newFreeCredits = userBalance.freeCreditsRemaining - freeCreditsUsed;
const newBalance = userBalance.balance - paidCreditsUsed;
const newBalance = userBalance.balance - totalCredits;
const updateResult = await tx
.update(balances)
.set({
balance: newBalance,
freeCreditsRemaining: newFreeCredits,
version: userBalance.version + 1,
updatedAt: new Date(),
})
@ -175,11 +169,11 @@ export class GiftCodeService {
.insert(transactions)
.values({
userId,
type: 'gift_reserve',
type: 'gift',
status: 'completed',
amount: -totalCredits,
balanceBefore: totalAvailable,
balanceAfter: newBalance + newFreeCredits,
balanceBefore: userBalance.balance,
balanceAfter: newBalance,
appId: dto.sourceAppId || 'gift',
description: `Gift code reservation: ${code}`,
completedAt: new Date(),
@ -427,15 +421,14 @@ export class GiftCodeService {
.limit(1);
if (!redeemerBalance) {
// Initialize balance
// Initialize balance (starts at 0)
[redeemerBalance] = await tx
.insert(balances)
.values({
userId,
balance: 0,
freeCreditsRemaining: 150, // Signup bonus
dailyFreeCredits: 5,
lastDailyResetAt: new Date(),
totalEarned: 0,
totalSpent: 0,
})
.returning();
}
@ -460,14 +453,13 @@ export class GiftCodeService {
.insert(transactions)
.values({
userId,
type: 'gift_receive',
type: 'gift',
status: 'completed',
amount: creditsToAdd,
balanceBefore: redeemerBalance.balance,
balanceAfter: newBalance,
appId: dto.sourceAppId || 'gift',
description: `Gift received: ${giftCode.code}`,
organizationId: null,
metadata: { giftCodeId: giftCode.id, portionNumber },
idempotencyKey: null,
completedAt: new Date(),
@ -570,7 +562,7 @@ export class GiftCodeService {
// 5. Create refund transaction
await tx.insert(transactions).values({
userId,
type: 'gift_release',
type: 'refund',
status: 'completed',
amount: refundAmount,
balanceBefore: creatorBalance.balance,
@ -687,4 +679,59 @@ export class GiftCodeService {
redeemedAt: r.redeemedAt.toISOString(),
}));
}
/**
* Automatically redeem pending gifts for a newly registered user
* Called during registration to give users any gifts sent to their email before signup
*/
async redeemPendingGifts(
userId: string,
email: string
): Promise<{ redeemedCount: number; totalCredits: number }> {
const db = this.getDb();
// Find active gift codes with targetEmail matching the user's email
const pendingGifts = await db
.select()
.from(giftCodes)
.where(and(eq(giftCodes.targetEmail, email.toLowerCase()), eq(giftCodes.status, 'active')));
if (pendingGifts.length === 0) {
return { redeemedCount: 0, totalCredits: 0 };
}
let redeemedCount = 0;
let totalCredits = 0;
// Redeem each pending gift
for (const gift of pendingGifts) {
try {
const result = await this.redeemGiftCode(
userId,
gift.code,
{ sourceAppId: 'registration' },
email
);
if (result.success) {
redeemedCount++;
totalCredits += result.credits || 0;
this.logger.log('Auto-redeemed pending gift on registration', {
userId,
code: gift.code,
credits: result.credits,
});
}
} catch (error) {
this.logger.warn('Failed to auto-redeem pending gift', {
userId,
code: gift.code,
error: error instanceof Error ? error.message : 'Unknown error',
});
// Continue with other gifts even if one fails
}
}
return { redeemedCount, totalCredits };
}
}

View file

@ -691,29 +691,29 @@ export class ReferralTrackingService {
throw new NotFoundException('User balance not found');
}
const newFreeCredits = currentBalance.freeCreditsRemaining + amount;
const newBalance = currentBalance.balance + amount;
const newTotalEarned = currentBalance.totalEarned + amount;
// Update balance
// Update balance (add to main balance, not free credits)
await db
.update(balances)
.set({
freeCreditsRemaining: newFreeCredits,
balance: newBalance,
totalEarned: newTotalEarned,
updatedAt: new Date(),
})
.where(eq(balances.userId, userId));
// Create transaction record
// Create transaction record (using 'gift' type for referral bonuses)
const [transaction] = await db
.insert(transactions)
.values({
userId,
type: 'bonus',
type: 'gift',
status: 'completed',
amount,
balanceBefore: currentBalance.balance + currentBalance.freeCreditsRemaining,
balanceAfter: currentBalance.balance + newFreeCredits,
balanceBefore: currentBalance.balance,
balanceAfter: newBalance,
appId: 'referral',
description: `Referral bonus: ${reason}`,
completedAt: new Date(),