trading-platform-backend-v2/src/modules/investment/services/transaction.service.ts
Adrian Flores Cortes 4322caf69a [OQI-004] feat: Complete PostgreSQL migration for investment module
- withdrawal.repository.ts: CRUD for withdrawal_requests table
- distribution.repository.ts: CRUD for profit_distributions table
- product.repository.ts: CRUD for products table with DB/in-memory fallback
- transaction.service.ts: Migrated withdrawal and distribution to repositories
- product.service.ts: Added DB support with in-memory defaults fallback

All investment entities now persist to PostgreSQL instead of in-memory storage.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 07:57:18 -06:00

659 lines
20 KiB
TypeScript

/**
* 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<Transaction | null> {
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<Transaction> {
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<Transaction> {
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<Transaction> {
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<WithdrawalRequest[]> {
const repoWithdrawals = await withdrawalRepository.findByUserId(
userId,
status as RepoWithdrawalStatus | undefined
);
return repoWithdrawals.map(mapRepoWithdrawalToService);
}
/**
* Get withdrawal request by ID
*/
async getWithdrawalById(withdrawalId: string): Promise<WithdrawalRequest | null> {
const repo = await withdrawalRepository.findById(withdrawalId);
if (!repo) return null;
return mapRepoWithdrawalToService(repo);
}
/**
* Create a withdrawal request
*/
async createWithdrawal(
userId: string,
input: CreateWithdrawalInput
): Promise<WithdrawalRequest> {
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<WithdrawalRequest> {
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<WithdrawalRequest> {
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<WithdrawalRequest> {
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<Distribution[]> {
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<Distribution> {
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<Distribution> {
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<Distribution[]> {
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();