From bfc2737ce58e3ce543608646b8e26750452587ec Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:54:32 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(credits):=20simpl?= =?UTF-8?q?ify=20credit=20system=20by=20removing=20free=20credits=20and=20?= =?UTF-8?q?B2B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/manacore/apps/web/src/lib/api/credits.ts | 4 +- .../dashboard/widgets/CreditsWidget.svelte | 4 - .../web/src/routes/(app)/credits/+page.svelte | 60 +- .../src/services/credit-client.service.ts | 10 +- .../src/__tests__/utils/mock-factories.ts | 7 +- .../src/__tests__/utils/test-helpers.ts | 2 - .../auth/services/better-auth.service.spec.ts | 58 +- .../src/auth/services/better-auth.service.ts | 70 +- .../src/common/guards/jwt-auth.guard.spec.ts | 7 +- .../src/config/configuration.ts | 5 +- .../src/config/env.validation.ts | 4 - .../src/credits/credits.controller.spec.ts | 419 +----- .../src/credits/credits.controller.ts | 53 +- .../src/credits/credits.service.spec.ts | 1279 +---------------- .../src/credits/credits.service.ts | 491 +------ .../src/credits/dto/allocate-credits.dto.ts | 17 - .../db/migrations/0001_simplify_credits.sql | 40 + .../src/db/schema/credits.schema.ts | 59 +- .../src/gifts/services/gift-code.service.ts | 85 +- .../services/referral-tracking.service.ts | 14 +- 20 files changed, 272 insertions(+), 2416 deletions(-) delete mode 100644 services/mana-core-auth/src/credits/dto/allocate-credits.dto.ts create mode 100644 services/mana-core-auth/src/db/migrations/0001_simplify_credits.sql diff --git a/apps/manacore/apps/web/src/lib/api/credits.ts b/apps/manacore/apps/web/src/lib/api/credits.ts index f6e937b58..b1dd2bd13 100644 --- a/apps/manacore/apps/web/src/lib/api/credits.ts +++ b/apps/manacore/apps/web/src/lib/api/credits.ts @@ -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; diff --git a/apps/manacore/apps/web/src/lib/components/dashboard/widgets/CreditsWidget.svelte b/apps/manacore/apps/web/src/lib/components/dashboard/widgets/CreditsWidget.svelte index 465b030b3..65d490f92 100644 --- a/apps/manacore/apps/web/src/lib/components/dashboard/widgets/CreditsWidget.svelte +++ b/apps/manacore/apps/web/src/lib/components/dashboard/widgets/CreditsWidget.svelte @@ -53,10 +53,6 @@ {$_('dashboard.widgets.credits.available')} {formatCredits(data.balance)} -
- {$_('dashboard.widgets.credits.free_today')} - {data.freeCreditsRemaining}/{data.dailyFreeCredits} -
{:else} -
+

Verfügbare Credits

@@ -170,14 +168,6 @@

- -
-

Gratis-Credits heute

-

- {balance?.freeCreditsRemaining ?? 0} / {balance?.dailyFreeCredits ?? 5} -

-
-

Gesamt erhalten

@@ -281,11 +271,23 @@
{#if processingPackageId === pkg.id} - - + + {:else} - {formatPrice(pkg.priceEuroCents)} + {formatPrice(pkg.priceEuroCents)} {/if} {/each} @@ -362,8 +364,19 @@ > {#if processingPackageId === pkg.id} - - + + Wird geladen... {:else} @@ -388,7 +401,8 @@ {#if toastMessage}
diff --git a/packages/mana-core-nestjs-integration/src/services/credit-client.service.ts b/packages/mana-core-nestjs-integration/src/services/credit-client.service.ts index 8e3e2d17e..5d190adbc 100644 --- a/packages/mana-core-nestjs-integration/src/services/credit-client.service.ts +++ b/packages/mana-core-nestjs-integration/src/services/credit-client.service.ts @@ -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 { 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, }; diff --git a/services/mana-core-auth/src/__tests__/utils/mock-factories.ts b/services/mana-core-auth/src/__tests__/utils/mock-factories.ts index b39d3fb99..82b266b21 100644 --- a/services/mana-core-auth/src/__tests__/utils/mock-factories.ts +++ b/services/mana-core-auth/src/__tests__/utils/mock-factories.ts @@ -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 = {}) => ({ 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, }); }, }; diff --git a/services/mana-core-auth/src/__tests__/utils/test-helpers.ts b/services/mana-core-auth/src/__tests__/utils/test-helpers.ts index 24e9fa101..34b8cdedf 100644 --- a/services/mana-core-auth/src/__tests__/utils/test-helpers.ts +++ b/services/mana-core-auth/src/__tests__/utils/test-helpers.ts @@ -18,8 +18,6 @@ export const createMockConfigService = (overrides: Record = {}): 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', diff --git a/services/mana-core-auth/src/auth/services/better-auth.service.spec.ts b/services/mana-core-auth/src/auth/services/better-auth.service.spec.ts index 9d7c2ce3a..ef2717037 100644 --- a/services/mana-core-auth/src/auth/services/better-auth.service.spec.ts +++ b/services/mana-core-auth/src/auth/services/better-auth.service.spec.ts @@ -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, }) ); }); diff --git a/services/mana-core-auth/src/auth/services/better-auth.service.ts b/services/mana-core-auth/src/auth/services/better-auth.service.ts index 772cba5d6..8b430b0bf 100644 --- a/services/mana-core-auth/src/auth/services/better-auth.service.ts +++ b/services/mana-core-auth/src/auth/services/better-auth.service.ts @@ -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 * diff --git a/services/mana-core-auth/src/common/guards/jwt-auth.guard.spec.ts b/services/mana-core-auth/src/common/guards/jwt-auth.guard.spec.ts index d0dd1851a..264f6b93a 100644 --- a/services/mana-core-auth/src/common/guards/jwt-auth.guard.spec.ts +++ b/services/mana-core-auth/src/common/guards/jwt-auth.guard.spec.ts @@ -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', }), diff --git a/services/mana-core-auth/src/config/configuration.ts b/services/mana-core-auth/src/config/configuration.ts index 7ab7dc08b..0db253839 100644 --- a/services/mana-core-auth/src/config/configuration.ts +++ b/services/mana-core-auth/src/config/configuration.ts @@ -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 || '', diff --git a/services/mana-core-auth/src/config/env.validation.ts b/services/mana-core-auth/src/config/env.validation.ts index 88f067f26..3223070ef 100644 --- a/services/mana-core-auth/src/config/env.validation.ts +++ b/services/mana-core-auth/src/config/env.validation.ts @@ -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(), diff --git a/services/mana-core-auth/src/credits/credits.controller.spec.ts b/services/mana-core-auth/src/credits/credits.controller.spec.ts index 1accad709..c7dab92f0 100644 --- a/services/mana-core-auth/src/credits/credits.controller.spec.ts +++ b/services/mana-core-auth/src/credits/credits.controller.spec.ts @@ -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 }); diff --git a/services/mana-core-auth/src/credits/credits.controller.ts b/services/mana-core-auth/src/credits/credits.controller.ts index 2658030f3..7c26664f7 100644 --- a/services/mana-core-auth/src/credits/credits.controller.ts +++ b/services/mana-core-auth/src/credits/credits.controller.ts @@ -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); - } } diff --git a/services/mana-core-auth/src/credits/credits.service.spec.ts b/services/mana-core-auth/src/credits/credits.service.spec.ts index 66be9dbd7..0f4b7548c 100644 --- a/services/mana-core-auth/src/credits/credits.service.spec.ts +++ b/services/mana-core-auth/src/credits/credits.service.spec.ts @@ -5,20 +5,17 @@ * - Balance initialization * - Credit usage with optimistic locking * - Transaction history - * - Daily free credit reset * - 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, - ForbiddenException, -} from '@nestjs/common'; +import { BadRequestException, NotFoundException, ConflictException } from '@nestjs/common'; import { CreditsService } from './credits.service'; +import { StripeService } from '../stripe/stripe.service'; import { createMockConfigService } from '../__tests__/utils/test-helpers'; import { mockUserFactory, @@ -26,10 +23,6 @@ import { mockTransactionFactory, mockPackageFactory, mockPurchaseFactory, - mockOrganizationFactory, - mockOrganizationBalanceFactory, - mockMemberFactory, - mockCreditAllocationFactory, } from '../__tests__/utils/mock-factories'; jest.mock('../db/connection'); @@ -75,15 +68,22 @@ describe('CreditsService', () => { const { getDb } = require('../db/connection'); getDb.mockReturnValue(mockDb); + const mockStripeService = { + getCustomerByUserId: jest.fn(), + createCheckoutSession: jest.fn(), + handleWebhook: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ providers: [ CreditsService, { provide: ConfigService, - useValue: createMockConfigService({ - 'credits.signupBonus': 150, - 'credits.dailyFreeCredits': 5, - }), + useValue: createMockConfigService({}), + }, + { + provide: StripeService, + useValue: mockStripeService, }, ], }).compile(); @@ -97,37 +97,37 @@ describe('CreditsService', () => { }); describe('initializeUserBalance', () => { - it('should create initial balance with signup bonus', async () => { + it('should create initial balance with zero credits', async () => { const userId = 'user-123'; const mockBalance = mockBalanceFactory.create(userId, { balance: 0, - freeCreditsRemaining: 150, + totalEarned: 0, + totalSpent: 0, }); - // Mock query results in order: check existing, create balance, create transaction + // Mock query results in order: check existing, create balance mockDb.mockResults( [], // No existing balance - [mockBalance], // Create balance - [{}] // Create transaction + [mockBalance] // Create balance ); const result = await service.initializeUserBalance(userId); expect(result).toEqual(mockBalance); - // Verify balance was created with correct values + // Verify balance was created with correct values (simplified - no free credits) expect(mockDb.values).toHaveBeenCalledWith( expect.objectContaining({ userId, balance: 0, - freeCreditsRemaining: 150, - dailyFreeCredits: 5, + totalEarned: 0, + totalSpent: 0, }) ); - // Verify signup bonus transaction was created - expect(mockDb.insert).toHaveBeenCalledTimes(2); // balance + transaction + // Verify only balance was created (no signup bonus transaction) + expect(mockDb.insert).toHaveBeenCalledTimes(1); }); it('should not create duplicate balance if already exists', async () => { @@ -142,136 +142,53 @@ describe('CreditsService', () => { expect(result).toEqual(existingBalance); - // Verify no new balance was created (insert should only be called for SELECT) - // Actually since existing balance found, no insert at all - }); - - it('should create bonus transaction record with correct details', async () => { - const userId = 'user-123'; - - mockDb.mockResults( - [], // No existing balance - [mockBalanceFactory.create(userId)], - [{}] - ); - - await service.initializeUserBalance(userId); - - // Verify transaction record for signup bonus - expect(mockDb.values).toHaveBeenCalledWith( - expect.objectContaining({ - userId, - type: 'bonus', - status: 'completed', - amount: 150, - appId: 'system', - description: 'Signup bonus', - }) - ); + // Verify no new balance was created }); }); describe('getBalance', () => { - it('should return user balance with daily reset check', async () => { + it('should return user balance', async () => { const userId = 'user-123'; const mockBalance = mockBalanceFactory.create(userId, { balance: 1000, - freeCreditsRemaining: 50, totalEarned: 2000, totalSpent: 1000, }); - // Mock query results: daily reset check, return balance - mockDb.mockResults( - [mockBalance], // Get balance (for daily reset check) - [mockBalance] // Get balance (for return) - ); + mockDb.mockResults([mockBalance]); const result = await service.getBalance(userId); - // The service returns the full balance object expect(result).toMatchObject({ balance: 1000, - freeCreditsRemaining: 50, totalEarned: 2000, totalSpent: 1000, - dailyFreeCredits: 5, }); }); it('should initialize balance if it does not exist', async () => { const userId = 'user-new'; - const newBalance = mockBalanceFactory.create(userId); + const newBalance = mockBalanceFactory.create(userId, { + balance: 0, + totalEarned: 0, + totalSpent: 0, + }); mockDb.mockResults( - [], // No balance found (for daily reset check) - [], // No existing balance (for initialization) - [newBalance], // Created balance - [{}] // Transaction + [], // No balance found + [newBalance] // Created balance ); const result = await service.getBalance(userId); expect(result).toMatchObject({ balance: 0, - freeCreditsRemaining: 150, + totalEarned: 0, + totalSpent: 0, }); }); - - it('should apply daily free credits reset if needed', async () => { - const userId = 'user-123'; - - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - - const mockBalance = mockBalanceFactory.create(userId, { - freeCreditsRemaining: 50, - dailyFreeCredits: 5, - lastDailyResetAt: yesterday, - }); - - const updatedBalance = mockBalanceFactory.create(userId, { - freeCreditsRemaining: 55, // 50 + 5 - }); - - mockDb.mockResults( - [mockBalance], // Get balance (for daily reset check) - [{}], // Update balance (daily reset) - [{}], // Insert transaction (daily bonus) - [updatedBalance] // Get balance (for return) - ); - - await service.getBalance(userId); - - // Verify daily reset was applied - expect(mockDb.update).toHaveBeenCalled(); - expect(mockDb.set).toHaveBeenCalledWith( - expect.objectContaining({ - freeCreditsRemaining: 55, - lastDailyResetAt: expect.any(Date), - }) - ); - }); - - it('should not reset if last reset was today', async () => { - const userId = 'user-123'; - - const mockBalance = mockBalanceFactory.create(userId, { - lastDailyResetAt: new Date(), // Today - }); - - mockDb.mockResults( - [mockBalance], // Daily reset check - [mockBalance] // Return - ); - - await service.getBalance(userId); - - // Verify no update was made - expect(mockDb.update).not.toHaveBeenCalled(); - }); }); describe('useCredits', () => { @@ -286,7 +203,6 @@ describe('CreditsService', () => { const mockBalance = mockBalanceFactory.create(userId, { balance: 100, - freeCreditsRemaining: 50, totalSpent: 0, version: 0, }); @@ -304,7 +220,7 @@ describe('CreditsService', () => { returning: jest.fn().mockResolvedValue([ { ...mockBalance, - freeCreditsRemaining: 40, + balance: 90, totalSpent: 10, version: 1, }, @@ -316,8 +232,8 @@ describe('CreditsService', () => { txMock.returning.mockResolvedValue([ mockTransactionFactory.create(userId, { amount: -10, - balanceBefore: 150, - balanceAfter: 140, + balanceBefore: 100, + balanceAfter: 90, }), ]); @@ -330,8 +246,7 @@ describe('CreditsService', () => { expect(result.transaction).toBeDefined(); if ('newBalance' in result) { expect(result.newBalance).toMatchObject({ - balance: 100, - freeCreditsRemaining: 40, + balance: 90, totalSpent: 10, }); } @@ -347,7 +262,6 @@ describe('CreditsService', () => { const mockBalance = mockBalanceFactory.create(userId, { balance: 50, - freeCreditsRemaining: 100, }); mockDb.transaction.mockImplementation(async (callback: any) => { @@ -392,59 +306,6 @@ describe('CreditsService', () => { ); }); - it('should prioritize free credits over paid credits', async () => { - const userId = 'user-123'; - const useCreditsDto = { - amount: 30, - appId: 'chat', - description: 'Chat usage', - }; - - const mockBalance = mockBalanceFactory.create(userId, { - balance: 100, // Paid credits - freeCreditsRemaining: 20, // Free credits - version: 0, - }); - - let capturedFreeCredits: number | undefined; - let capturedPaidCredits: number | undefined; - - 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((values: any) => { - capturedFreeCredits = values.freeCreditsRemaining; - capturedPaidCredits = values.balance; - return txMock; - }), - returning: jest.fn().mockResolvedValue([ - { - ...mockBalance, - balance: 90, - freeCreditsRemaining: 0, - }, - ]), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - }; - - txMock.returning.mockResolvedValue([mockTransactionFactory.create(userId)]); - - return callback(txMock); - }); - - await service.useCredits(userId, useCreditsDto); - - // Verify: 20 free credits used + 10 paid credits used - expect(capturedFreeCredits).toBe(0); // 20 - 20 - expect(capturedPaidCredits).toBe(90); // 100 - 10 - }); - it('should implement optimistic locking to prevent race conditions', async () => { const userId = 'user-123'; const useCreditsDto = { @@ -521,7 +382,6 @@ describe('CreditsService', () => { const mockBalance = mockBalanceFactory.create(userId, { balance: 100, - freeCreditsRemaining: 0, version: 0, }); @@ -579,7 +439,6 @@ describe('CreditsService', () => { const mockBalance = mockBalanceFactory.create(userId, { balance: 100, - freeCreditsRemaining: 0, }); let capturedUsageStats: any; @@ -724,100 +583,7 @@ describe('CreditsService', () => { }); }); - describe('Daily Credit Reset Logic', () => { - it('should reset credits at midnight', async () => { - const userId = 'user-123'; - - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - yesterday.setHours(23, 59, 59); - - const mockBalance = mockBalanceFactory.create(userId, { - freeCreditsRemaining: 100, - dailyFreeCredits: 5, - lastDailyResetAt: yesterday, - }); - - mockDb.mockResults( - [mockBalance], // For checkDailyReset - [], // Update result - [], // Transaction result - [{ ...mockBalance, freeCreditsRemaining: 105 }] // Final balance - ); - - await service.getBalance(userId); - - expect(mockDb.update).toHaveBeenCalled(); - }); - - it('should not reset if last reset was same day', async () => { - const userId = 'user-123'; - - const today = new Date(); - today.setHours(8, 0, 0); // Earlier today - - const mockBalance = mockBalanceFactory.create(userId, { - lastDailyResetAt: today, - }); - - mockDb.mockResults( - [mockBalance], // checkDailyReset - [mockBalance] // getBalance return - ); - - await service.getBalance(userId); - - expect(mockDb.update).not.toHaveBeenCalled(); - }); - - it('should handle month boundary correctly', async () => { - const userId = 'user-123'; - - // Last reset: Last day of previous month - const lastMonth = new Date(); - lastMonth.setMonth(lastMonth.getMonth() - 1); - lastMonth.setDate(28); // Adjust for month length - - const mockBalance = mockBalanceFactory.create(userId, { - freeCreditsRemaining: 50, - dailyFreeCredits: 5, - lastDailyResetAt: lastMonth, - }); - - mockDb.mockResults([mockBalance], [], [], [{ ...mockBalance, freeCreditsRemaining: 55 }]); - - await service.getBalance(userId); - - expect(mockDb.update).toHaveBeenCalled(); - }); - - it('should create transaction record for daily bonus', async () => { - const userId = 'user-123'; - - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - - const mockBalance = mockBalanceFactory.create(userId, { - balance: 100, - freeCreditsRemaining: 50, - dailyFreeCredits: 5, - lastDailyResetAt: yesterday, - }); - - mockDb.mockResults( - [mockBalance], - [], // Update - [], // Transaction insert - [{ ...mockBalance, freeCreditsRemaining: 55 }] - ); - - await service.getBalance(userId); - - // Note: The actual implementation would capture this - // This test validates the logic flow - expect(mockDb.insert).toHaveBeenCalled(); - }); - }); + // Daily Credit Reset Logic tests removed - functionality simplified (no daily free credits) describe('Edge Cases', () => { it('should handle zero credit usage', async () => { @@ -860,14 +626,13 @@ describe('CreditsService', () => { it('should handle exact balance deduction', async () => { const userId = 'user-123'; const useCreditsDto = { - amount: 150, + amount: 100, appId: 'test', description: 'Exact balance test', }; const mockBalance = mockBalanceFactory.create(userId, { balance: 100, - freeCreditsRemaining: 50, }); mockDb.transaction.mockImplementation(async (callback: any) => { @@ -883,7 +648,6 @@ describe('CreditsService', () => { { ...mockBalance, balance: 0, - freeCreditsRemaining: 0, }, ]), insert: jest.fn().mockReturnThis(), @@ -900,964 +664,9 @@ describe('CreditsService', () => { expect(result.success).toBe(true); if ('newBalance' in result) { expect(result.newBalance.balance).toBe(0); - expect(result.newBalance.freeCreditsRemaining).toBe(0); } }); }); - // ============================================================================ - // ORGANIZATION CREDIT TESTS (B2B) - // ============================================================================ - - describe('createOrganizationCreditBalance', () => { - it('should create new organization balance with zeros', async () => { - const organizationId = 'org-123'; - - const mockOrgBalance = mockOrganizationBalanceFactory.create(organizationId); - - // Mock query results: check existing, create balance - mockDb.mockResults( - [], // No existing balance - [mockOrgBalance] // Create balance - ); - - const result = await service.createOrganizationCreditBalance(organizationId); - - expect(result).toEqual(mockOrgBalance); - - // Verify balance was created with correct values - expect(mockDb.values).toHaveBeenCalledWith( - expect.objectContaining({ - organizationId, - balance: 0, - allocatedCredits: 0, - availableCredits: 0, - totalPurchased: 0, - totalAllocated: 0, - }) - ); - }); - - it('should not create duplicate if already exists', async () => { - const organizationId = 'org-123'; - - const existingBalance = mockOrganizationBalanceFactory.create(organizationId, { - balance: 1000, - allocatedCredits: 500, - availableCredits: 500, - }); - - // Mock: Balance already exists - mockDb.mockResults([existingBalance]); - - const result = await service.createOrganizationCreditBalance(organizationId); - - expect(result).toEqual(existingBalance); - - // When balance exists, no insert is called - }); - - it('should return existing balance if already present', async () => { - const organizationId = 'org-456'; - - const existingBalance = mockOrganizationBalanceFactory.create(organizationId, { - balance: 5000, - allocatedCredits: 2000, - availableCredits: 3000, - totalPurchased: 5000, - totalAllocated: 2000, - }); - - mockDb.mockResults([existingBalance]); - - const result = await service.createOrganizationCreditBalance(organizationId); - - expect(result).toEqual(existingBalance); - expect(result.balance).toBe(5000); - expect(result.allocatedCredits).toBe(2000); - expect(result.availableCredits).toBe(3000); - }); - }); - - describe('allocateCredits', () => { - it('should allocate credits from org to employee successfully', async () => { - const allocatorUserId = 'owner-123'; - const employeeId = 'employee-456'; - const organizationId = 'org-789'; - const allocateDto = { - organizationId, - employeeId, - amount: 100, - reason: 'Monthly allocation', - }; - - const mockOwner = mockMemberFactory.createOwner(organizationId, allocatorUserId); - const mockOrgBalance = mockOrganizationBalanceFactory.create(organizationId, { - balance: 1000, - allocatedCredits: 200, - availableCredits: 800, - version: 0, - }); - const mockEmployeeBalance = mockBalanceFactory.create(employeeId, { - balance: 50, - version: 0, - }); - - mockDb.transaction.mockImplementation(async (callback: any) => { - const txMock: any = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - for: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - returning: jest.fn(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - }; - - // Mock member check (owner) - uses .limit(1) as terminal - txMock.limit.mockResolvedValueOnce([mockOwner]); - - // Mock org balance retrieval - uses .for('update').limit(1) - txMock.limit.mockResolvedValueOnce([mockOrgBalance]); - - // Mock employee balance retrieval - uses .for('update').limit(1).then() - txMock.limit.mockReturnValueOnce({ - then: (callback: any) => callback([mockEmployeeBalance]), - }); - - // Mock org balance update - txMock.returning.mockResolvedValueOnce([ - { - ...mockOrgBalance, - allocatedCredits: 300, - availableCredits: 700, - totalAllocated: 300, - version: 1, - }, - ]); - - // Mock employee balance update - txMock.returning.mockResolvedValueOnce([ - { - ...mockEmployeeBalance, - balance: 150, - totalEarned: 100, - version: 1, - }, - ]); - - // Mock allocation record insert - const mockAllocation = mockCreditAllocationFactory.create( - organizationId, - employeeId, - allocatorUserId, - { - amount: 100, - balanceBefore: 50, - balanceAfter: 150, - } - ); - txMock.returning.mockResolvedValueOnce([mockAllocation]); - - // Mock transaction record insert - txMock.returning.mockResolvedValueOnce([{}]); - - return callback(txMock); - }); - - const result = await service.allocateCredits(allocatorUserId, allocateDto); - - expect(result.success).toBe(true); - expect(result.allocation).toBeDefined(); - expect(result.organizationBalance.allocatedCredits).toBe(300); - expect(result.organizationBalance.availableCredits).toBe(700); - expect(result.employeeBalance.balance).toBe(150); - }); - - it('should throw ForbiddenException if allocator is not owner', async () => { - const allocatorUserId = 'member-123'; // Not an owner - const employeeId = 'employee-456'; - const organizationId = 'org-789'; - const allocateDto = { - organizationId, - employeeId, - amount: 100, - }; - - const mockMember = mockMemberFactory.create(organizationId, allocatorUserId, { - role: 'member', // Not owner - }); - - mockDb.transaction.mockImplementation(async (callback: any) => { - const txMock: any = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn(), - returning: jest.fn(), - }; - - // Mock member check (not owner) - uses .limit(1) as terminal - txMock.limit.mockResolvedValueOnce([mockMember]); - - return callback(txMock); - }); - - await expect(service.allocateCredits(allocatorUserId, allocateDto)).rejects.toThrow( - ForbiddenException - ); - }); - - it('should throw BadRequestException if org has insufficient available credits', async () => { - const allocatorUserId = 'owner-123'; - const employeeId = 'employee-456'; - const organizationId = 'org-789'; - const allocateDto = { - organizationId, - employeeId, - amount: 1000, // More than available - }; - - const mockOwner = mockMemberFactory.createOwner(organizationId, allocatorUserId); - const mockOrgBalance = mockOrganizationBalanceFactory.create(organizationId, { - balance: 1000, - allocatedCredits: 700, - availableCredits: 300, // Only 300 available - }); - - mockDb.transaction.mockImplementation(async (callback: any) => { - const txMock: any = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn(), - for: jest.fn().mockReturnThis(), - returning: jest.fn(), - }; - - // Mock member check (owner) - uses .limit(1) as terminal - txMock.limit.mockResolvedValueOnce([mockOwner]); - - // Mock org balance retrieval - uses .for('update').limit(1) - txMock.limit.mockResolvedValueOnce([mockOrgBalance]); - - return callback(txMock); - }); - - await expect(service.allocateCredits(allocatorUserId, allocateDto)).rejects.toThrow( - BadRequestException - ); - }); - - it('should auto-create employee balance if it does not exist', async () => { - const allocatorUserId = 'owner-123'; - const employeeId = 'new-employee-456'; - const organizationId = 'org-789'; - const allocateDto = { - organizationId, - employeeId, - amount: 100, - }; - - const mockOwner = mockMemberFactory.createOwner(organizationId, allocatorUserId); - const mockOrgBalance = mockOrganizationBalanceFactory.create(organizationId, { - balance: 1000, - allocatedCredits: 0, - availableCredits: 1000, - version: 0, - }); - - mockDb.transaction.mockImplementation(async (callback: any) => { - const txMock: any = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn(), - for: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - returning: jest.fn(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - }; - - // Mock member check - uses .limit(1) as terminal - txMock.limit.mockResolvedValueOnce([mockOwner]); - - // Mock org balance retrieval - uses .for('update').limit(1) - txMock.limit.mockResolvedValueOnce([mockOrgBalance]); - - // Mock employee balance retrieval (not found) - uses .for('update').limit(1).then() - txMock.limit.mockReturnValueOnce({ - then: (callback: any) => callback([]), // No employee balance - }); - - // Mock employee balance creation - const newEmployeeBalance = mockBalanceFactory.create(employeeId, { - balance: 0, - freeCreditsRemaining: 150, - }); - txMock.returning.mockResolvedValueOnce([newEmployeeBalance]); - - // Mock org balance update - txMock.returning.mockResolvedValueOnce([ - { - ...mockOrgBalance, - allocatedCredits: 100, - availableCredits: 900, - version: 1, - }, - ]); - - // Mock employee balance update - txMock.returning.mockResolvedValueOnce([ - { - ...newEmployeeBalance, - balance: 100, - version: 1, - }, - ]); - - // Mock allocation record - txMock.returning.mockResolvedValueOnce([ - mockCreditAllocationFactory.create(organizationId, employeeId, allocatorUserId), - ]); - - // Mock transaction record - txMock.returning.mockResolvedValueOnce([{}]); - - return callback(txMock); - }); - - const result = await service.allocateCredits(allocatorUserId, allocateDto); - - expect(result.success).toBe(true); - expect(result.employeeBalance.balance).toBe(100); - }); - - it('should use transaction for atomicity', async () => { - const allocatorUserId = 'owner-123'; - const employeeId = 'employee-456'; - const organizationId = 'org-789'; - const allocateDto = { - organizationId, - employeeId, - amount: 100, - }; - - const mockOwner = mockMemberFactory.createOwner(organizationId, allocatorUserId); - const mockOrgBalance = mockOrganizationBalanceFactory.create(organizationId, { - balance: 1000, - allocatedCredits: 0, - availableCredits: 1000, - }); - const mockEmployeeBalance = mockBalanceFactory.create(employeeId); - - mockDb.transaction.mockImplementation(async (callback: any) => { - const txMock: any = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn(), - for: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - returning: jest.fn(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - }; - - // Mock member check - uses .limit(1) - txMock.limit.mockResolvedValueOnce([mockOwner]); - // Mock org balance - uses .for('update').limit(1) - txMock.limit.mockResolvedValueOnce([mockOrgBalance]); - // Mock employee balance - uses .for('update').limit(1).then() - txMock.limit.mockReturnValueOnce({ - then: (callback: any) => callback([mockEmployeeBalance]), - }); - txMock.returning.mockResolvedValueOnce([mockOrgBalance]); - txMock.returning.mockResolvedValueOnce([mockEmployeeBalance]); - txMock.returning.mockResolvedValueOnce([ - mockCreditAllocationFactory.create(organizationId, employeeId, allocatorUserId), - ]); - txMock.returning.mockResolvedValueOnce([{}]); - - return callback(txMock); - }); - - await service.allocateCredits(allocatorUserId, allocateDto); - - // Verify transaction was used - expect(mockDb.transaction).toHaveBeenCalledTimes(1); - }); - - it('should update both org available_credits and employee balance', async () => { - const allocatorUserId = 'owner-123'; - const employeeId = 'employee-456'; - const organizationId = 'org-789'; - const allocateDto = { - organizationId, - employeeId, - amount: 200, - }; - - const mockOwner = mockMemberFactory.createOwner(organizationId, allocatorUserId); - const mockOrgBalance = mockOrganizationBalanceFactory.create(organizationId, { - balance: 1000, - allocatedCredits: 300, - availableCredits: 700, - totalAllocated: 300, - version: 0, - }); - const mockEmployeeBalance = mockBalanceFactory.create(employeeId, { - balance: 100, - totalEarned: 50, - version: 0, - }); - - let capturedOrgUpdate: any; - let capturedEmployeeUpdate: any; - - mockDb.transaction.mockImplementation(async (callback: any) => { - const txMock: any = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn(), - for: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - set: jest.fn((values: any) => { - if (values.allocatedCredits !== undefined) { - capturedOrgUpdate = values; - } else if (values.balance !== undefined) { - capturedEmployeeUpdate = values; - } - return txMock; - }), - returning: jest.fn(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - }; - - // Mock member check - uses .limit(1) - txMock.limit.mockResolvedValueOnce([mockOwner]); - // Mock org balance - uses .for('update').limit(1) - txMock.limit.mockResolvedValueOnce([mockOrgBalance]); - // Mock employee balance - uses .for('update').limit(1).then() - txMock.limit.mockReturnValueOnce({ - then: (callback: any) => callback([mockEmployeeBalance]), - }); - txMock.returning.mockResolvedValueOnce([ - { - ...mockOrgBalance, - allocatedCredits: 500, - availableCredits: 500, - totalAllocated: 500, - }, - ]); - txMock.returning.mockResolvedValueOnce([ - { - ...mockEmployeeBalance, - balance: 300, - totalEarned: 250, - }, - ]); - txMock.returning.mockResolvedValueOnce([ - mockCreditAllocationFactory.create(organizationId, employeeId, allocatorUserId), - ]); - txMock.returning.mockResolvedValueOnce([{}]); - - return callback(txMock); - }); - - await service.allocateCredits(allocatorUserId, allocateDto); - - // Verify org update - expect(capturedOrgUpdate).toMatchObject({ - allocatedCredits: 500, // 300 + 200 - availableCredits: 500, // 1000 - 500 - totalAllocated: 500, - }); - - // Verify employee update - expect(capturedEmployeeUpdate).toMatchObject({ - balance: 300, // 100 + 200 - totalEarned: 250, // 50 + 200 - }); - }); - - it('should create allocation record for audit', async () => { - const allocatorUserId = 'owner-123'; - const employeeId = 'employee-456'; - const organizationId = 'org-789'; - const allocateDto = { - organizationId, - employeeId, - amount: 150, - reason: 'Q4 allocation', - }; - - const mockOwner = mockMemberFactory.createOwner(organizationId, allocatorUserId); - const mockOrgBalance = mockOrganizationBalanceFactory.create(organizationId, { - balance: 1000, - availableCredits: 1000, - }); - const mockEmployeeBalance = mockBalanceFactory.create(employeeId, { - balance: 50, - }); - - let capturedAllocationValues: any; - - mockDb.transaction.mockImplementation(async (callback: any) => { - const txMock: any = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn(), - for: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - returning: jest.fn(), - insert: jest.fn().mockReturnThis(), - values: jest.fn((values: any) => { - if (values.allocatedBy !== undefined) { - capturedAllocationValues = values; - } - return txMock; - }), - }; - - // Mock member check - uses .limit(1) - txMock.limit.mockResolvedValueOnce([mockOwner]); - // Mock org balance - uses .for('update').limit(1) - txMock.limit.mockResolvedValueOnce([mockOrgBalance]); - // Mock employee balance - uses .for('update').limit(1).then() - txMock.limit.mockReturnValueOnce({ - then: (callback: any) => callback([mockEmployeeBalance]), - }); - txMock.returning.mockResolvedValueOnce([mockOrgBalance]); - txMock.returning.mockResolvedValueOnce([mockEmployeeBalance]); - txMock.returning.mockResolvedValueOnce([ - mockCreditAllocationFactory.create(organizationId, employeeId, allocatorUserId), - ]); - txMock.returning.mockResolvedValueOnce([{}]); - - return callback(txMock); - }); - - await service.allocateCredits(allocatorUserId, allocateDto); - - expect(capturedAllocationValues).toMatchObject({ - organizationId, - employeeId, - amount: 150, - allocatedBy: allocatorUserId, - reason: 'Q4 allocation', - balanceBefore: 50, - balanceAfter: 200, - }); - }); - - it('should handle optimistic locking for concurrent allocations', async () => { - const allocatorUserId = 'owner-123'; - const employeeId = 'employee-456'; - const organizationId = 'org-789'; - const allocateDto = { - organizationId, - employeeId, - amount: 100, - }; - - const mockOwner = mockMemberFactory.createOwner(organizationId, allocatorUserId); - const mockOrgBalance = mockOrganizationBalanceFactory.create(organizationId, { - balance: 1000, - availableCredits: 1000, - version: 5, - }); - const mockEmployeeBalance = mockBalanceFactory.create(employeeId, { - version: 3, - }); - - mockDb.transaction.mockImplementation(async (callback: any) => { - const txMock: any = { - select: jest.fn().mockReturnThis(), - from: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn(), - for: jest.fn().mockReturnThis(), - update: jest.fn().mockReturnThis(), - set: jest.fn().mockReturnThis(), - returning: jest.fn(), - insert: jest.fn().mockReturnThis(), - values: jest.fn().mockReturnThis(), - }; - - // Mock member check - uses .limit(1) - txMock.limit.mockResolvedValueOnce([mockOwner]); - // Mock org balance - uses .for('update').limit(1) - txMock.limit.mockResolvedValueOnce([mockOrgBalance]); - // Mock employee balance - uses .for('update').limit(1).then() - txMock.limit.mockReturnValueOnce({ - then: (callback: any) => callback([mockEmployeeBalance]), - }); - - // Simulate version conflict on org balance update - txMock.returning.mockResolvedValueOnce([]); // Empty result = conflict - - return callback(txMock); - }); - - await expect(service.allocateCredits(allocatorUserId, allocateDto)).rejects.toThrow( - ConflictException - ); - await expect(service.allocateCredits(allocatorUserId, allocateDto)).rejects.toThrow( - 'Organization balance was modified by another transaction' - ); - }); - }); - - describe('getEmployeeCreditBalance', () => { - it('should return employee credit balance', async () => { - const userId = 'employee-123'; - - const mockBalance = mockBalanceFactory.create(userId, { - balance: 500, - freeCreditsRemaining: 50, - totalEarned: 1000, - totalSpent: 450, - }); - - // The implementation uses .limit(1) as the terminal method, not .returning() - mockDb.limit.mockResolvedValueOnce([mockBalance]); - - const result = await service.getEmployeeCreditBalance(userId); - - expect(result).toEqual({ - balance: 500, - freeCreditsRemaining: 50, - totalEarned: 1000, - totalSpent: 450, - }); - }); - - it('should return null if no balance exists', async () => { - const userId = 'employee-new'; - - // Mock: No balance found - .limit(1) returns empty array - mockDb.limit.mockResolvedValueOnce([]); - - const result = await service.getEmployeeCreditBalance(userId); - - expect(result).toBeNull(); - }); - - it('should work with organizationId parameter (optional)', async () => { - const userId = 'employee-123'; - const organizationId = 'org-789'; - - const mockBalance = mockBalanceFactory.create(userId, { - balance: 300, - }); - - // .limit(1) is the terminal method - mockDb.limit.mockResolvedValueOnce([mockBalance]); - - const result = await service.getEmployeeCreditBalance(userId, organizationId); - - expect(result).toBeDefined(); - expect(result?.balance).toBe(300); - }); - }); - - describe('getOrganizationBalance', () => { - it('should return complete org balance breakdown', async () => { - const organizationId = 'org-123'; - - const mockOrgBalance = mockOrganizationBalanceFactory.create(organizationId, { - balance: 10000, - allocatedCredits: 4000, - availableCredits: 6000, - totalPurchased: 10000, - totalAllocated: 4000, - }); - - const mockAllocations = [ - mockCreditAllocationFactory.create(organizationId, 'emp-1', 'owner-1', { - amount: 100, - }), - mockCreditAllocationFactory.create(organizationId, 'emp-2', 'owner-1', { - amount: 200, - }), - ]; - - // Mock org balance query - uses .limit(1) as terminal - mockDb.limit.mockResolvedValueOnce([mockOrgBalance]); - - // Mock allocations query - also uses .limit(10) as terminal - mockDb.limit.mockResolvedValueOnce(mockAllocations); - - const result = await service.getOrganizationBalance(organizationId); - - expect(result).toEqual({ - balance: 10000, - allocatedCredits: 4000, - availableCredits: 6000, - totalPurchased: 10000, - totalAllocated: 4000, - recentAllocations: mockAllocations, - }); - }); - - it('should include recent allocations', async () => { - const organizationId = 'org-456'; - - const mockOrgBalance = mockOrganizationBalanceFactory.create(organizationId, { - balance: 5000, - }); - - const recentAllocations = [ - mockCreditAllocationFactory.create(organizationId, 'emp-1', 'owner-1', { - amount: 500, - reason: 'Monthly allocation', - }), - mockCreditAllocationFactory.create(organizationId, 'emp-2', 'owner-1', { - amount: 300, - reason: 'Bonus allocation', - }), - mockCreditAllocationFactory.create(organizationId, 'emp-3', 'owner-1', { - amount: 200, - reason: 'Project allocation', - }), - ]; - - // Mock org balance query - uses .limit(1) as terminal - mockDb.limit.mockResolvedValueOnce([mockOrgBalance]); - - // Mock allocations query - uses .limit(10) as terminal - mockDb.limit.mockResolvedValueOnce(recentAllocations); - - const result = await service.getOrganizationBalance(organizationId); - - expect(result.recentAllocations).toHaveLength(3); - expect(result.recentAllocations[0].reason).toBe('Monthly allocation'); - }); - - it('should throw NotFoundException if org does not exist', async () => { - const organizationId = 'org-nonexistent'; - - // Mock: No org balance found - .limit(1) returns empty - mockDb.limit.mockResolvedValueOnce([]); - - await expect(service.getOrganizationBalance(organizationId)).rejects.toThrow( - NotFoundException - ); - await expect(service.getOrganizationBalance(organizationId)).rejects.toThrow( - 'Organization balance not found' - ); - }); - }); - - describe('deductCredits (with organization tracking)', () => { - it('should track organization_id in transaction for B2B users', async () => { - const userId = 'employee-123'; - const organizationId = 'org-789'; - const useCreditsDto = { - amount: 10, - appId: 'chat', - description: 'Chat usage', - }; - - const mockBalance = mockBalanceFactory.create(userId, { - balance: 100, - version: 0, - }); - - let capturedTransactionValues: 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) => { - if (values.type === 'usage') { - capturedTransactionValues = values; - } - return txMock; - }), - }; - - txMock.returning.mockResolvedValue([mockTransactionFactory.create(userId)]); - - return callback(txMock); - }); - - await service.deductCredits(userId, useCreditsDto, organizationId); - - expect(capturedTransactionValues).toMatchObject({ - userId, - type: 'usage', - amount: -10, - organizationId: organizationId, // B2B tracking - appId: 'chat', - description: 'Chat usage', - }); - }); - - it('should set organization_id to null for B2C users', async () => { - const userId = 'b2c-user-123'; - const useCreditsDto = { - amount: 10, - appId: 'picture', - description: 'Image generation', - }; - - const mockBalance = mockBalanceFactory.create(userId, { - balance: 100, - version: 0, - }); - - let capturedTransactionValues: 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) => { - if (values.type === 'usage') { - capturedTransactionValues = values; - } - return txMock; - }), - }; - - txMock.returning.mockResolvedValue([mockTransactionFactory.create(userId)]); - - return callback(txMock); - }); - - // Call without organizationId - await service.deductCredits(userId, useCreditsDto); - - expect(capturedTransactionValues).toMatchObject({ - userId, - type: 'usage', - amount: -10, - organizationId: null, // B2C - no org tracking - }); - }); - - it('should work with existing idempotency', async () => { - const userId = 'user-123'; - const useCreditsDto = { - amount: 10, - appId: 'chat', - description: 'Chat usage', - idempotencyKey: 'unique-key-abc', - }; - - const existingTransaction = mockTransactionFactory.create(userId, { - idempotencyKey: 'unique-key-abc', - }); - - // Idempotency check uses .limit(1) as terminal method - mockDb.limit.mockResolvedValueOnce([existingTransaction]); - - const result = await service.deductCredits(userId, useCreditsDto, 'org-123'); - - 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 work with existing optimistic locking', async () => { - const userId = 'user-123'; - const organizationId = 'org-789'; - 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.deductCredits(userId, useCreditsDto, organizationId)).rejects.toThrow( - ConflictException - ); - await expect(service.deductCredits(userId, useCreditsDto, organizationId)).rejects.toThrow( - 'Balance was modified by another transaction' - ); - }); - - it('should handle insufficient credits error', async () => { - const userId = 'user-123'; - const organizationId = 'org-789'; - const useCreditsDto = { - amount: 1000, - appId: 'picture', - description: 'Image generation', - }; - - const mockBalance = mockBalanceFactory.create(userId, { - balance: 50, - freeCreditsRemaining: 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]), - }; - return callback(txMock); - }); - - await expect(service.deductCredits(userId, useCreditsDto, organizationId)).rejects.toThrow( - BadRequestException - ); - await expect(service.deductCredits(userId, useCreditsDto, organizationId)).rejects.toThrow( - 'Insufficient credits' - ); - }); - }); + // B2B organization credit tests removed - functionality simplified to B2C only }); diff --git a/services/mana-core-auth/src/credits/credits.service.ts b/services/mana-core-auth/src/credits/credits.service.ts index 3ee3715b3..c1baf5b66 100644 --- a/services/mana-core-auth/src/credits/credits.service.ts +++ b/services/mana-core-auth/src/credits/credits.service.ts @@ -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('credits.signupBonus') || 150; - const dailyFreeCredits = this.configService.get('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('credits.signupBonus') || 150; - const dailyFreeCredits = this.configService.get('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('credits.signupBonus') || 150; - const dailyFreeCredits = this.configService.get('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('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 diff --git a/services/mana-core-auth/src/credits/dto/allocate-credits.dto.ts b/services/mana-core-auth/src/credits/dto/allocate-credits.dto.ts deleted file mode 100644 index 4f0a064e0..000000000 --- a/services/mana-core-auth/src/credits/dto/allocate-credits.dto.ts +++ /dev/null @@ -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; -} diff --git a/services/mana-core-auth/src/db/migrations/0001_simplify_credits.sql b/services/mana-core-auth/src/db/migrations/0001_simplify_credits.sql new file mode 100644 index 000000000..be3c197e2 --- /dev/null +++ b/services/mana-core-auth/src/db/migrations/0001_simplify_credits.sql @@ -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 diff --git a/services/mana-core-auth/src/db/schema/credits.schema.ts b/services/mana-core-auth/src/db/schema/credits.schema.ts index 0d1f643cd..833faa2c8 100644 --- a/services/mana-core-auth/src/db/schema/credits.schema.ts +++ b/services/mana-core-auth/src/db/schema/credits.schema.ts @@ -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 diff --git a/services/mana-core-auth/src/gifts/services/gift-code.service.ts b/services/mana-core-auth/src/gifts/services/gift-code.service.ts index cdc869eb1..19f1c9fae 100644 --- a/services/mana-core-auth/src/gifts/services/gift-code.service.ts +++ b/services/mana-core-auth/src/gifts/services/gift-code.service.ts @@ -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 }; + } } diff --git a/services/mana-core-auth/src/referrals/services/referral-tracking.service.ts b/services/mana-core-auth/src/referrals/services/referral-tracking.service.ts index 1f87ebb10..cfeeca08d 100644 --- a/services/mana-core-auth/src/referrals/services/referral-tracking.service.ts +++ b/services/mana-core-auth/src/referrals/services/referral-tracking.service.ts @@ -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(),