/** * Investment Transaction Service * Manages deposits, withdrawals, and distributions * * Now uses PostgreSQL repository for transactions */ import { accountService } from './account.service'; import { transactionRepository, Transaction as RepoTransaction, TransactionType as RepoTransactionType, TransactionStatus as RepoTransactionStatus, } from '../repositories/transaction.repository'; import { withdrawalRepository, WithdrawalRequest as RepoWithdrawal, WithdrawalStatus as RepoWithdrawalStatus, } from '../repositories/withdrawal.repository'; import { distributionRepository, Distribution as RepoDistribution, DistributionStatus as RepoDistributionStatus, } from '../repositories/distribution.repository'; // ============================================================================ // Types // ============================================================================ export type TransactionType = 'deposit' | 'withdrawal' | 'earning' | 'fee' | 'distribution'; export type TransactionStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; export type WithdrawalStatus = 'pending' | 'processing' | 'completed' | 'rejected'; export interface Transaction { id: string; accountId: string; transactionNumber: string; userId: string; type: TransactionType; status: TransactionStatus; amount: number; balanceBefore: number | null; balanceAfter: number | null; stripePaymentId: string | null; description: string; processedAt: Date | null; createdAt: Date; } // Helper to map repo transaction type to service type function mapRepoTypeToServiceType(repoType: RepoTransactionType): TransactionType { if (repoType === 'distribution') return 'earning'; return repoType; } // Helper to map service type to repo type function mapServiceTypeToRepoType(serviceType: TransactionType): RepoTransactionType { if (serviceType === 'earning' || serviceType === 'fee') return 'distribution'; if (serviceType === 'deposit' || serviceType === 'withdrawal' || serviceType === 'distribution') { return serviceType; } return 'deposit'; // fallback } // Helper to map repo transaction to service transaction function mapRepoToService(repo: RepoTransaction, userId: string): Transaction { return { id: repo.id, accountId: repo.accountId, transactionNumber: repo.transactionNumber, userId, type: mapRepoTypeToServiceType(repo.transactionType), status: repo.status, amount: repo.amount, balanceBefore: repo.balanceBefore, balanceAfter: repo.balanceAfter, stripePaymentId: repo.paymentReference, description: repo.notes || `${repo.transactionType} transaction`, processedAt: repo.completedAt || repo.processedAt, createdAt: repo.createdAt, }; } export interface WithdrawalRequest { id: string; accountId: string; userId: string; amount: number; status: WithdrawalStatus; bankInfo: { bankName: string; accountNumber: string; routingNumber: string; accountHolderName: string; } | null; cryptoInfo: { network: string; address: string; } | null; rejectionReason: string | null; requestedAt: Date; processedAt: Date | null; completedAt: Date | null; } export interface Distribution { id: string; accountId: string; userId: string; periodStart: Date; periodEnd: Date; grossEarnings: number; performanceFee: number; netEarnings: number; status: 'pending' | 'distributed'; distributedAt: Date | null; createdAt: Date; } export interface CreateDepositInput { accountId: string; amount: number; stripePaymentId?: string; } export interface CreateWithdrawalInput { accountId: string; amount: number; bankInfo?: { bankName: string; accountNumber: string; routingNumber: string; accountHolderName: string; }; cryptoInfo?: { network: string; address: string; }; } // ============================================================================ // Helper Functions for Withdrawal Mapping // ============================================================================ function mapRepoWithdrawalToService(repo: RepoWithdrawal): WithdrawalRequest { return { id: repo.id, accountId: repo.accountId, userId: repo.userId, amount: repo.amount, status: repo.status as WithdrawalStatus, bankInfo: null, // Bank info stored in metadata if needed cryptoInfo: null, // Crypto info stored in metadata if needed rejectionReason: repo.rejectionReason, requestedAt: repo.createdAt, processedAt: repo.processedAt, completedAt: repo.status === 'completed' ? repo.processedAt : null, }; } // ============================================================================ // Helper Functions for Distribution Mapping // ============================================================================ function mapRepoDistributionToService(repo: RepoDistribution, userId: string): Distribution { return { id: repo.id, accountId: repo.accountId, userId, periodStart: repo.periodStart, periodEnd: repo.periodEnd, grossEarnings: repo.grossProfit, performanceFee: repo.managementFee, netEarnings: repo.clientAmount, status: repo.status === 'distributed' ? 'distributed' : 'pending', distributedAt: repo.distributedAt, createdAt: repo.createdAt, }; } // ============================================================================ // Transaction Service // ============================================================================ class TransactionService { // ========================================================================== // Transactions // ========================================================================== /** * Get transactions for an account */ async getAccountTransactions( accountId: string, options: { type?: TransactionType; status?: TransactionStatus; limit?: number; offset?: number; } = {} ): Promise<{ transactions: Transaction[]; total: number }> { // Get account to retrieve userId const account = await accountService.getAccountById(accountId); const userId = account?.userId || ''; const result = await transactionRepository.findByAccountId(accountId, { transactionType: options.type ? mapServiceTypeToRepoType(options.type) : undefined, status: options.status as RepoTransactionStatus | undefined, limit: options.limit, offset: options.offset, }); return { transactions: result.transactions.map((t) => mapRepoToService(t, userId)), total: result.total, }; } /** * Get transaction by ID */ async getTransactionById(transactionId: string): Promise { const repo = await transactionRepository.findById(transactionId); if (!repo) return null; // Get account to retrieve userId const account = await accountService.getAccountById(repo.accountId); const userId = account?.userId || ''; return mapRepoToService(repo, userId); } /** * Create a deposit transaction */ async createDeposit(input: CreateDepositInput): Promise { const account = await accountService.getAccountById(input.accountId); if (!account) { throw new Error(`Account not found: ${input.accountId}`); } if (input.amount <= 0) { throw new Error('Deposit amount must be positive'); } const balanceBefore = account.balance; // Create transaction record first const repo = await transactionRepository.create({ accountId: input.accountId, transactionType: 'deposit', amount: input.amount, paymentReference: input.stripePaymentId, balanceBefore, notes: `Deposit of $${input.amount.toFixed(2)}`, }); // Process deposit in account const updatedAccount = await accountService.deposit(input.accountId, input.amount); // Update transaction with completed status const completed = await transactionRepository.updateStatus(repo.id, 'completed', { balanceAfter: updatedAccount.balance, }); return mapRepoToService(completed || repo, account.userId); } /** * Create a pending deposit (for Stripe webhooks) */ async createPendingDeposit( accountId: string, amount: number, stripePaymentId: string ): Promise { const account = await accountService.getAccountById(accountId); if (!account) { throw new Error(`Account not found: ${accountId}`); } const repo = await transactionRepository.create({ accountId, transactionType: 'deposit', amount, paymentReference: stripePaymentId, balanceBefore: account.balance, notes: `Pending deposit of $${amount.toFixed(2)}`, }); return mapRepoToService(repo, account.userId); } /** * Complete a pending deposit */ async completeDeposit(transactionId: string): Promise { const repo = await transactionRepository.findById(transactionId); if (!repo) { throw new Error(`Transaction not found: ${transactionId}`); } if (repo.status !== 'pending') { throw new Error('Transaction is not pending'); } // Process deposit in account const updatedAccount = await accountService.deposit(repo.accountId, repo.amount); // Update transaction status const completed = await transactionRepository.updateStatus(transactionId, 'completed', { balanceAfter: updatedAccount.balance, }); // Update notes if (completed) { await transactionRepository.update(transactionId, { notes: `Deposit of $${repo.amount.toFixed(2)}`, }); } return mapRepoToService(completed || repo, updatedAccount.userId); } // ========================================================================== // Withdrawals // ========================================================================== /** * Get withdrawal requests for a user */ async getUserWithdrawals( userId: string, status?: WithdrawalStatus ): Promise { const repoWithdrawals = await withdrawalRepository.findByUserId( userId, status as RepoWithdrawalStatus | undefined ); return repoWithdrawals.map(mapRepoWithdrawalToService); } /** * Get withdrawal request by ID */ async getWithdrawalById(withdrawalId: string): Promise { const repo = await withdrawalRepository.findById(withdrawalId); if (!repo) return null; return mapRepoWithdrawalToService(repo); } /** * Create a withdrawal request */ async createWithdrawal( userId: string, input: CreateWithdrawalInput ): Promise { const account = await accountService.getAccountById(input.accountId); if (!account) { throw new Error(`Account not found: ${input.accountId}`); } if (account.userId !== userId) { throw new Error('Unauthorized'); } if (account.status !== 'active') { throw new Error('Cannot withdraw from inactive account'); } if (input.amount <= 0) { throw new Error('Withdrawal amount must be positive'); } if (input.amount > account.balance) { throw new Error('Insufficient balance'); } if (!input.bankInfo && !input.cryptoInfo) { throw new Error('Bank or crypto information is required'); } // Check daily withdrawal limit using repository const dailyLimit = 10000; const todayWithdrawals = await withdrawalRepository.getDailyTotal(userId); if (todayWithdrawals + input.amount > dailyLimit) { throw new Error(`Daily withdrawal limit of $${dailyLimit} exceeded`); } // Create withdrawal request in database const destinationType = input.bankInfo ? 'bank_transfer' : 'wallet'; const repo = await withdrawalRepository.create({ accountId: input.accountId, userId, amount: input.amount, destinationType, }); const withdrawal = mapRepoWithdrawalToService(repo); // Add bank/crypto info to the response withdrawal.bankInfo = input.bankInfo || null; withdrawal.cryptoInfo = input.cryptoInfo || null; // Create pending transaction in database await transactionRepository.create({ accountId: input.accountId, transactionType: 'withdrawal', amount: input.amount, balanceBefore: account.balance, requiresApproval: true, notes: `Pending withdrawal of $${input.amount.toFixed(2)}`, }); return withdrawal; } /** * Process a withdrawal (admin) */ async processWithdrawal(withdrawalId: string, processedBy: string = 'system'): Promise { const repo = await withdrawalRepository.findById(withdrawalId); if (!repo) { throw new Error(`Withdrawal not found: ${withdrawalId}`); } if (repo.status !== 'pending') { throw new Error('Withdrawal is not pending'); } const updated = await withdrawalRepository.process(withdrawalId, processedBy); if (!updated) { throw new Error('Failed to process withdrawal'); } return mapRepoWithdrawalToService(updated); } /** * Complete a withdrawal (admin) */ async completeWithdrawal(withdrawalId: string): Promise { const repo = await withdrawalRepository.findById(withdrawalId); if (!repo) { throw new Error(`Withdrawal not found: ${withdrawalId}`); } if (repo.status !== 'processing') { throw new Error('Withdrawal is not being processed'); } // Deduct from account using accountService.withdraw const updatedAccount = await accountService.withdraw(repo.accountId, repo.amount); // Complete withdrawal in database const completed = await withdrawalRepository.complete(withdrawalId); if (!completed) { throw new Error('Failed to complete withdrawal'); } // Find and update the pending transaction in database const { transactions: pendingTxs } = await transactionRepository.findByAccountId( repo.accountId, { transactionType: 'withdrawal', status: 'pending' } ); const pendingTx = pendingTxs.find((t) => t.amount === repo.amount); if (pendingTx) { await transactionRepository.updateStatus(pendingTx.id, 'completed', { balanceAfter: updatedAccount.balance, }); await transactionRepository.update(pendingTx.id, { notes: `Withdrawal of $${repo.amount.toFixed(2)}`, }); } return mapRepoWithdrawalToService(completed); } /** * Reject a withdrawal (admin) */ async rejectWithdrawal( withdrawalId: string, reason: string, processedBy: string = 'system' ): Promise { const repo = await withdrawalRepository.findById(withdrawalId); if (!repo) { throw new Error(`Withdrawal not found: ${withdrawalId}`); } if (repo.status === 'completed') { throw new Error('Cannot reject completed withdrawal'); } // Reject withdrawal in database const rejected = await withdrawalRepository.reject(withdrawalId, reason, processedBy); if (!rejected) { throw new Error('Failed to reject withdrawal'); } // Find and cancel the pending transaction in database const { transactions: pendingTxs } = await transactionRepository.findByAccountId( repo.accountId, { transactionType: 'withdrawal', status: 'pending' } ); const pendingTx = pendingTxs.find((t) => t.amount === repo.amount); if (pendingTx) { await transactionRepository.updateStatus(pendingTx.id, 'cancelled', { failureReason: reason, }); await transactionRepository.update(pendingTx.id, { notes: `Withdrawal rejected: ${reason}`, }); } return mapRepoWithdrawalToService(rejected); } // ========================================================================== // Distributions // ========================================================================== /** * Get distributions for an account */ async getAccountDistributions(accountId: string): Promise { const account = await accountService.getAccountById(accountId); const userId = account?.userId || ''; const repoDistributions = await distributionRepository.findByAccountId(accountId); return repoDistributions.map((d) => mapRepoDistributionToService(d, userId)); } /** * Create a distribution */ async createDistribution( accountId: string, periodStart: Date, periodEnd: Date, grossEarnings: number, performanceFeePercent: number ): Promise { const account = await accountService.getAccountById(accountId); if (!account) { throw new Error(`Account not found: ${accountId}`); } // Platform takes performance fee, client gets the rest const platformSharePercent = performanceFeePercent; const clientSharePercent = 100 - performanceFeePercent; const repo = await distributionRepository.create({ accountId, periodStart, periodEnd, grossProfit: grossEarnings, managementFee: 0, platformSharePercent, clientSharePercent, }); return mapRepoDistributionToService(repo, account.userId); } /** * Distribute earnings */ async distributeEarnings(distributionId: string): Promise { const repo = await distributionRepository.findById(distributionId); if (!repo) { throw new Error(`Distribution not found: ${distributionId}`); } if (repo.status === 'distributed') { throw new Error('Distribution already completed'); } const account = await accountService.getAccountById(repo.accountId); if (!account) { throw new Error(`Account not found: ${repo.accountId}`); } // Get account balance before earnings const balanceBefore = account.balance; // Record earnings in account (client amount) await accountService.recordEarnings( repo.accountId, repo.grossProfit, repo.platformAmount ); // Get updated account const updatedAccount = await accountService.getAccountById(repo.accountId); // Create distribution transaction in database (client earnings) if (repo.clientAmount !== 0) { const earningTx = await transactionRepository.create({ accountId: repo.accountId, transactionType: 'distribution', amount: repo.clientAmount, balanceBefore, balanceAfter: updatedAccount?.balance, distributionId, notes: `Earnings for ${repo.periodStart.toLocaleDateString()} - ${repo.periodEnd.toLocaleDateString()}`, }); // Mark as completed immediately await transactionRepository.updateStatus(earningTx.id, 'completed'); } // Mark distribution as distributed const distributed = await distributionRepository.distribute(distributionId); if (!distributed) { throw new Error('Failed to mark distribution as distributed'); } return mapRepoDistributionToService(distributed, account.userId); } /** * Get pending distributions */ async getPendingDistributions(): Promise { const pendingDistributions = await distributionRepository.findPending(); // Get user IDs for each distribution const results: Distribution[] = []; for (const repo of pendingDistributions) { const account = await accountService.getAccountById(repo.accountId); const userId = account?.userId || ''; results.push(mapRepoDistributionToService(repo, userId)); } return results; } /** * Get distribution statistics */ async getDistributionStats(): Promise<{ pendingCount: number; pendingAmount: number; distributedCount: number; distributedAmount: number; }> { return distributionRepository.getStats(); } } // Export singleton instance export const transactionService = new TransactionService();