diff --git a/src/modules/investment/repositories/distribution.repository.ts b/src/modules/investment/repositories/distribution.repository.ts new file mode 100644 index 0000000..3a06b2f --- /dev/null +++ b/src/modules/investment/repositories/distribution.repository.ts @@ -0,0 +1,354 @@ +/** + * Investment Distribution Repository + * Handles database operations for profit distributions + */ + +import { db } from '../../../shared/database'; + +// ============================================================================ +// Types +// ============================================================================ + +export type DistributionStatus = 'pending' | 'approved' | 'distributed' | 'cancelled'; + +export interface DistributionRow { + id: string; + account_id: string; + period_start: Date; + period_end: Date; + gross_profit: string; + management_fee: string; + net_profit: string; + platform_share_percent: string; + client_share_percent: string; + platform_amount: string; + client_amount: string; + status: DistributionStatus; + distributed_at: Date | null; + approved_by: string | null; + payment_reference: string | null; + created_at: Date; + updated_at: Date; +} + +export interface Distribution { + id: string; + accountId: string; + periodStart: Date; + periodEnd: Date; + grossProfit: number; + managementFee: number; + netProfit: number; + platformSharePercent: number; + clientSharePercent: number; + platformAmount: number; + clientAmount: number; + status: DistributionStatus; + distributedAt: Date | null; + approvedBy: string | null; + paymentReference: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateDistributionInput { + accountId: string; + periodStart: Date; + periodEnd: Date; + grossProfit: number; + managementFee?: number; + platformSharePercent: number; + clientSharePercent: number; +} + +export interface DistributionFilters { + accountId?: string; + status?: DistributionStatus; + periodStart?: Date; + periodEnd?: Date; + limit?: number; + offset?: number; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function mapRowToDistribution(row: DistributionRow): Distribution { + return { + id: row.id, + accountId: row.account_id, + periodStart: row.period_start, + periodEnd: row.period_end, + grossProfit: parseFloat(row.gross_profit), + managementFee: parseFloat(row.management_fee || '0'), + netProfit: parseFloat(row.net_profit), + platformSharePercent: parseFloat(row.platform_share_percent), + clientSharePercent: parseFloat(row.client_share_percent), + platformAmount: parseFloat(row.platform_amount), + clientAmount: parseFloat(row.client_amount), + status: row.status, + distributedAt: row.distributed_at, + approvedBy: row.approved_by, + paymentReference: row.payment_reference, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +// ============================================================================ +// Repository Class +// ============================================================================ + +class DistributionRepository { + /** + * Create a new distribution + */ + async create(input: CreateDistributionInput): Promise { + const managementFee = input.managementFee || 0; + const netProfit = input.grossProfit - managementFee; + const platformAmount = netProfit * (input.platformSharePercent / 100); + const clientAmount = netProfit * (input.clientSharePercent / 100); + + const result = await db.query( + `INSERT INTO investment.profit_distributions ( + account_id, period_start, period_end, gross_profit, management_fee, + net_profit, platform_share_percent, client_share_percent, + platform_amount, client_amount, status + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'pending') + RETURNING *`, + [ + input.accountId, + input.periodStart, + input.periodEnd, + input.grossProfit, + managementFee, + netProfit, + input.platformSharePercent, + input.clientSharePercent, + platformAmount, + clientAmount, + ] + ); + + return mapRowToDistribution(result.rows[0]); + } + + /** + * Find distribution by ID + */ + async findById(id: string): Promise { + const result = await db.query( + 'SELECT * FROM investment.profit_distributions WHERE id = $1', + [id] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToDistribution(result.rows[0]); + } + + /** + * Find distributions by account + */ + async findByAccountId(accountId: string): Promise { + const result = await db.query( + `SELECT * FROM investment.profit_distributions + WHERE account_id = $1 + ORDER BY period_end DESC`, + [accountId] + ); + + return result.rows.map(mapRowToDistribution); + } + + /** + * Find all distributions with filters + */ + async findAll(filters: DistributionFilters = {}): Promise<{ + distributions: Distribution[]; + total: number; + }> { + const conditions: string[] = []; + const values: (string | Date | number)[] = []; + let paramIndex = 1; + + if (filters.accountId) { + conditions.push(`account_id = $${paramIndex++}`); + values.push(filters.accountId); + } + + if (filters.status) { + conditions.push(`status = $${paramIndex++}`); + values.push(filters.status); + } + + if (filters.periodStart) { + conditions.push(`period_start >= $${paramIndex++}`); + values.push(filters.periodStart); + } + + if (filters.periodEnd) { + conditions.push(`period_end <= $${paramIndex++}`); + values.push(filters.periodEnd); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Get total count + const countResult = await db.query<{ count: string }>( + `SELECT COUNT(*) as count FROM investment.profit_distributions ${whereClause}`, + values + ); + const total = parseInt(countResult.rows[0].count); + + // Get distributions + let query = ` + SELECT * FROM investment.profit_distributions + ${whereClause} + ORDER BY period_end DESC + `; + + if (filters.limit) { + query += ` LIMIT $${paramIndex++}`; + values.push(filters.limit); + } + + if (filters.offset) { + query += ` OFFSET $${paramIndex++}`; + values.push(filters.offset); + } + + const result = await db.query(query, values); + + return { + distributions: result.rows.map(mapRowToDistribution), + total, + }; + } + + /** + * Find pending distributions + */ + async findPending(): Promise { + const result = await db.query( + `SELECT * FROM investment.profit_distributions + WHERE status = 'pending' + ORDER BY period_end ASC` + ); + + return result.rows.map(mapRowToDistribution); + } + + /** + * Approve a distribution + */ + async approve(id: string, approvedBy: string): Promise { + const result = await db.query( + `UPDATE investment.profit_distributions + SET status = 'approved', approved_by = $2, updated_at = NOW() + WHERE id = $1 AND status = 'pending' + RETURNING *`, + [id, approvedBy] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToDistribution(result.rows[0]); + } + + /** + * Mark distribution as distributed + */ + async distribute( + id: string, + paymentReference?: string + ): Promise { + const result = await db.query( + `UPDATE investment.profit_distributions + SET status = 'distributed', distributed_at = NOW(), + payment_reference = $2, updated_at = NOW() + WHERE id = $1 AND status IN ('pending', 'approved') + RETURNING *`, + [id, paymentReference || null] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToDistribution(result.rows[0]); + } + + /** + * Cancel a distribution + */ + async cancel(id: string): Promise { + const result = await db.query( + `UPDATE investment.profit_distributions + SET status = 'cancelled', updated_at = NOW() + WHERE id = $1 AND status IN ('pending', 'approved') + RETURNING *`, + [id] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToDistribution(result.rows[0]); + } + + /** + * Get total distributed to an account + */ + async getTotalDistributed(accountId: string): Promise { + const result = await db.query<{ total: string }>( + `SELECT COALESCE(SUM(client_amount), 0) as total + FROM investment.profit_distributions + WHERE account_id = $1 AND status = 'distributed'`, + [accountId] + ); + + return parseFloat(result.rows[0].total); + } + + /** + * Get distribution statistics + */ + async getStats(): Promise<{ + pendingCount: number; + pendingAmount: number; + distributedCount: number; + distributedAmount: number; + }> { + const result = await db.query<{ + pending_count: string; + pending_amount: string; + distributed_count: string; + distributed_amount: string; + }>(` + SELECT + COUNT(*) FILTER (WHERE status = 'pending') as pending_count, + COALESCE(SUM(client_amount) FILTER (WHERE status = 'pending'), 0) as pending_amount, + COUNT(*) FILTER (WHERE status = 'distributed') as distributed_count, + COALESCE(SUM(client_amount) FILTER (WHERE status = 'distributed'), 0) as distributed_amount + FROM investment.profit_distributions + `); + + const row = result.rows[0]; + return { + pendingCount: parseInt(row.pending_count), + pendingAmount: parseFloat(row.pending_amount), + distributedCount: parseInt(row.distributed_count), + distributedAmount: parseFloat(row.distributed_amount), + }; + } +} + +// Export singleton instance +export const distributionRepository = new DistributionRepository(); diff --git a/src/modules/investment/repositories/index.ts b/src/modules/investment/repositories/index.ts index fcceee4..8471cdc 100644 --- a/src/modules/investment/repositories/index.ts +++ b/src/modules/investment/repositories/index.ts @@ -4,3 +4,15 @@ export * from './account.repository'; export * from './transaction.repository'; +export * from './withdrawal.repository'; +export * from './distribution.repository'; + +// Product repository exports (RiskProfile already exported from account.repository) +export { productRepository } from './product.repository'; +export type { + InvestmentProduct as ProductEntity, + ProductType, + ProductRow, + CreateProductInput, + UpdateProductInput, +} from './product.repository'; diff --git a/src/modules/investment/repositories/product.repository.ts b/src/modules/investment/repositories/product.repository.ts new file mode 100644 index 0000000..652ed8d --- /dev/null +++ b/src/modules/investment/repositories/product.repository.ts @@ -0,0 +1,437 @@ +/** + * Investment Product Repository + * Handles database operations for investment products + */ + +import { db } from '../../../shared/database'; + +// ============================================================================ +// Types +// ============================================================================ + +export type ProductType = 'fixed_return' | 'variable_return' | 'long_term_portfolio'; +export type RiskProfile = 'conservative' | 'moderate' | 'aggressive'; + +export interface ProductRow { + id: string; + name: string; + slug: string; + description: string | null; + short_description: string | null; + product_type: ProductType; + risk_profile: RiskProfile; + target_monthly_return: string | null; + max_drawdown: string | null; + guaranteed_return: boolean; + management_fee_percent: string; + performance_fee_percent: string; + profit_share_platform: string | null; + profit_share_client: string | null; + min_investment: string; + max_investment: string | null; + min_investment_period_days: number; + requires_kyc_level: number; + allowed_risk_profiles: RiskProfile[] | null; + default_bot_id: string | null; + is_active: boolean; + is_visible: boolean; + terms_url: string | null; + risk_disclosure_url: string | null; + created_at: Date; + updated_at: Date; +} + +export interface InvestmentProduct { + id: string; + name: string; + slug: string; + description: string | null; + shortDescription: string | null; + productType: ProductType; + riskProfile: RiskProfile; + targetMonthlyReturn: number | null; + maxDrawdown: number | null; + guaranteedReturn: boolean; + managementFeePercent: number; + performanceFeePercent: number; + profitSharePlatform: number | null; + profitShareClient: number | null; + minInvestment: number; + maxInvestment: number | null; + minInvestmentPeriodDays: number; + requiresKycLevel: number; + allowedRiskProfiles: RiskProfile[] | null; + defaultBotId: string | null; + isActive: boolean; + isVisible: boolean; + termsUrl: string | null; + riskDisclosureUrl: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateProductInput { + name: string; + slug: string; + description?: string; + shortDescription?: string; + productType: ProductType; + riskProfile: RiskProfile; + targetMonthlyReturn?: number; + maxDrawdown?: number; + managementFeePercent?: number; + performanceFeePercent?: number; + profitSharePlatform?: number; + profitShareClient?: number; + minInvestment?: number; + maxInvestment?: number; + minInvestmentPeriodDays?: number; + requiresKycLevel?: number; + allowedRiskProfiles?: RiskProfile[]; + defaultBotId?: string; + termsUrl?: string; + riskDisclosureUrl?: string; +} + +export interface UpdateProductInput { + name?: string; + description?: string; + shortDescription?: string; + targetMonthlyReturn?: number; + maxDrawdown?: number; + managementFeePercent?: number; + performanceFeePercent?: number; + profitSharePlatform?: number; + profitShareClient?: number; + minInvestment?: number; + maxInvestment?: number; + minInvestmentPeriodDays?: number; + requiresKycLevel?: number; + allowedRiskProfiles?: RiskProfile[]; + defaultBotId?: string; + isActive?: boolean; + isVisible?: boolean; + termsUrl?: string; + riskDisclosureUrl?: string; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function mapRowToProduct(row: ProductRow): InvestmentProduct { + return { + id: row.id, + name: row.name, + slug: row.slug, + description: row.description, + shortDescription: row.short_description, + productType: row.product_type, + riskProfile: row.risk_profile, + targetMonthlyReturn: row.target_monthly_return ? parseFloat(row.target_monthly_return) : null, + maxDrawdown: row.max_drawdown ? parseFloat(row.max_drawdown) : null, + guaranteedReturn: row.guaranteed_return, + managementFeePercent: parseFloat(row.management_fee_percent || '0'), + performanceFeePercent: parseFloat(row.performance_fee_percent || '0'), + profitSharePlatform: row.profit_share_platform ? parseFloat(row.profit_share_platform) : null, + profitShareClient: row.profit_share_client ? parseFloat(row.profit_share_client) : null, + minInvestment: parseFloat(row.min_investment), + maxInvestment: row.max_investment ? parseFloat(row.max_investment) : null, + minInvestmentPeriodDays: row.min_investment_period_days, + requiresKycLevel: row.requires_kyc_level, + allowedRiskProfiles: row.allowed_risk_profiles, + defaultBotId: row.default_bot_id, + isActive: row.is_active, + isVisible: row.is_visible, + termsUrl: row.terms_url, + riskDisclosureUrl: row.risk_disclosure_url, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +// ============================================================================ +// Repository Class +// ============================================================================ + +class ProductRepository { + /** + * Create a new product + */ + async create(input: CreateProductInput): Promise { + const result = await db.query( + `INSERT INTO investment.products ( + name, slug, description, short_description, product_type, risk_profile, + target_monthly_return, max_drawdown, management_fee_percent, performance_fee_percent, + profit_share_platform, profit_share_client, min_investment, max_investment, + min_investment_period_days, requires_kyc_level, allowed_risk_profiles, + default_bot_id, terms_url, risk_disclosure_url + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) + RETURNING *`, + [ + input.name, + input.slug, + input.description || null, + input.shortDescription || null, + input.productType, + input.riskProfile, + input.targetMonthlyReturn ?? null, + input.maxDrawdown ?? null, + input.managementFeePercent ?? 0, + input.performanceFeePercent ?? 0, + input.profitSharePlatform ?? null, + input.profitShareClient ?? null, + input.minInvestment ?? 100, + input.maxInvestment ?? null, + input.minInvestmentPeriodDays ?? 30, + input.requiresKycLevel ?? 1, + input.allowedRiskProfiles || null, + input.defaultBotId || null, + input.termsUrl || null, + input.riskDisclosureUrl || null, + ] + ); + + return mapRowToProduct(result.rows[0]); + } + + /** + * Find product by ID + */ + async findById(id: string): Promise { + const result = await db.query( + 'SELECT * FROM investment.products WHERE id = $1', + [id] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToProduct(result.rows[0]); + } + + /** + * Find product by slug + */ + async findBySlug(slug: string): Promise { + const result = await db.query( + 'SELECT * FROM investment.products WHERE slug = $1', + [slug] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToProduct(result.rows[0]); + } + + /** + * Find all visible and active products + */ + async findAllVisible(): Promise { + const result = await db.query( + `SELECT * FROM investment.products + WHERE is_active = true AND is_visible = true + ORDER BY min_investment ASC` + ); + + return result.rows.map(mapRowToProduct); + } + + /** + * Find all products (including inactive) + */ + async findAll(): Promise { + const result = await db.query( + `SELECT * FROM investment.products ORDER BY created_at DESC` + ); + + return result.rows.map(mapRowToProduct); + } + + /** + * Find products by risk profile + */ + async findByRiskProfile(riskProfile: RiskProfile): Promise { + const result = await db.query( + `SELECT * FROM investment.products + WHERE risk_profile = $1 AND is_active = true AND is_visible = true + ORDER BY min_investment ASC`, + [riskProfile] + ); + + return result.rows.map(mapRowToProduct); + } + + /** + * Find products by type + */ + async findByType(productType: ProductType): Promise { + const result = await db.query( + `SELECT * FROM investment.products + WHERE product_type = $1 AND is_active = true AND is_visible = true + ORDER BY min_investment ASC`, + [productType] + ); + + return result.rows.map(mapRowToProduct); + } + + /** + * Update product + */ + async update(id: string, input: UpdateProductInput): Promise { + const updates: string[] = []; + const values: (string | number | boolean | RiskProfile[] | null)[] = []; + let paramIndex = 1; + + if (input.name !== undefined) { + updates.push(`name = $${paramIndex++}`); + values.push(input.name); + } + + if (input.description !== undefined) { + updates.push(`description = $${paramIndex++}`); + values.push(input.description); + } + + if (input.shortDescription !== undefined) { + updates.push(`short_description = $${paramIndex++}`); + values.push(input.shortDescription); + } + + if (input.targetMonthlyReturn !== undefined) { + updates.push(`target_monthly_return = $${paramIndex++}`); + values.push(input.targetMonthlyReturn); + } + + if (input.maxDrawdown !== undefined) { + updates.push(`max_drawdown = $${paramIndex++}`); + values.push(input.maxDrawdown); + } + + if (input.managementFeePercent !== undefined) { + updates.push(`management_fee_percent = $${paramIndex++}`); + values.push(input.managementFeePercent); + } + + if (input.performanceFeePercent !== undefined) { + updates.push(`performance_fee_percent = $${paramIndex++}`); + values.push(input.performanceFeePercent); + } + + if (input.profitSharePlatform !== undefined) { + updates.push(`profit_share_platform = $${paramIndex++}`); + values.push(input.profitSharePlatform); + } + + if (input.profitShareClient !== undefined) { + updates.push(`profit_share_client = $${paramIndex++}`); + values.push(input.profitShareClient); + } + + if (input.minInvestment !== undefined) { + updates.push(`min_investment = $${paramIndex++}`); + values.push(input.minInvestment); + } + + if (input.maxInvestment !== undefined) { + updates.push(`max_investment = $${paramIndex++}`); + values.push(input.maxInvestment); + } + + if (input.minInvestmentPeriodDays !== undefined) { + updates.push(`min_investment_period_days = $${paramIndex++}`); + values.push(input.minInvestmentPeriodDays); + } + + if (input.requiresKycLevel !== undefined) { + updates.push(`requires_kyc_level = $${paramIndex++}`); + values.push(input.requiresKycLevel); + } + + if (input.allowedRiskProfiles !== undefined) { + updates.push(`allowed_risk_profiles = $${paramIndex++}`); + values.push(input.allowedRiskProfiles); + } + + if (input.defaultBotId !== undefined) { + updates.push(`default_bot_id = $${paramIndex++}`); + values.push(input.defaultBotId); + } + + if (input.isActive !== undefined) { + updates.push(`is_active = $${paramIndex++}`); + values.push(input.isActive); + } + + if (input.isVisible !== undefined) { + updates.push(`is_visible = $${paramIndex++}`); + values.push(input.isVisible); + } + + if (input.termsUrl !== undefined) { + updates.push(`terms_url = $${paramIndex++}`); + values.push(input.termsUrl); + } + + if (input.riskDisclosureUrl !== undefined) { + updates.push(`risk_disclosure_url = $${paramIndex++}`); + values.push(input.riskDisclosureUrl); + } + + if (updates.length === 0) { + return this.findById(id); + } + + updates.push(`updated_at = NOW()`); + values.push(id); + + const result = await db.query( + `UPDATE investment.products + SET ${updates.join(', ')} + WHERE id = $${paramIndex} + RETURNING *`, + values + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToProduct(result.rows[0]); + } + + /** + * Activate a product + */ + async activate(id: string): Promise { + return this.update(id, { isActive: true }); + } + + /** + * Deactivate a product + */ + async deactivate(id: string): Promise { + return this.update(id, { isActive: false }); + } + + /** + * Show a product + */ + async show(id: string): Promise { + return this.update(id, { isVisible: true }); + } + + /** + * Hide a product + */ + async hide(id: string): Promise { + return this.update(id, { isVisible: false }); + } +} + +// Export singleton instance +export const productRepository = new ProductRepository(); diff --git a/src/modules/investment/repositories/withdrawal.repository.ts b/src/modules/investment/repositories/withdrawal.repository.ts new file mode 100644 index 0000000..f2cdcbd --- /dev/null +++ b/src/modules/investment/repositories/withdrawal.repository.ts @@ -0,0 +1,342 @@ +/** + * Investment Withdrawal Repository + * Handles database operations for withdrawal requests + */ + +import { db } from '../../../shared/database'; + +// ============================================================================ +// Types +// ============================================================================ + +export type WithdrawalStatus = 'pending' | 'processing' | 'completed' | 'rejected'; +export type DestinationType = 'wallet' | 'bank_transfer'; + +export interface WithdrawalRow { + id: string; + account_id: string; + user_id: string; + amount: string; + currency: string; + destination_type: DestinationType; + destination_wallet_id: string | null; + status: WithdrawalStatus; + processed_at: Date | null; + processed_by: string | null; + rejection_reason: string | null; + fee_amount: string; + net_amount: string | null; + transaction_id: string | null; + created_at: Date; + updated_at: Date; +} + +export interface WithdrawalRequest { + id: string; + accountId: string; + userId: string; + amount: number; + currency: string; + destinationType: DestinationType; + destinationWalletId: string | null; + status: WithdrawalStatus; + processedAt: Date | null; + processedBy: string | null; + rejectionReason: string | null; + feeAmount: number; + netAmount: number | null; + transactionId: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateWithdrawalInput { + accountId: string; + userId: string; + amount: number; + currency?: string; + destinationType: DestinationType; + destinationWalletId?: string; + feeAmount?: number; +} + +export interface WithdrawalFilters { + accountId?: string; + userId?: string; + status?: WithdrawalStatus; + startDate?: Date; + endDate?: Date; + limit?: number; + offset?: number; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function mapRowToWithdrawal(row: WithdrawalRow): WithdrawalRequest { + return { + id: row.id, + accountId: row.account_id, + userId: row.user_id, + amount: parseFloat(row.amount), + currency: row.currency, + destinationType: row.destination_type, + destinationWalletId: row.destination_wallet_id, + status: row.status, + processedAt: row.processed_at, + processedBy: row.processed_by, + rejectionReason: row.rejection_reason, + feeAmount: parseFloat(row.fee_amount || '0'), + netAmount: row.net_amount ? parseFloat(row.net_amount) : null, + transactionId: row.transaction_id, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +// ============================================================================ +// Repository Class +// ============================================================================ + +class WithdrawalRepository { + /** + * Create a new withdrawal request + */ + async create(input: CreateWithdrawalInput): Promise { + const netAmount = input.amount - (input.feeAmount || 0); + + const result = await db.query( + `INSERT INTO investment.withdrawal_requests ( + account_id, user_id, amount, currency, destination_type, + destination_wallet_id, fee_amount, net_amount, status + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending') + RETURNING *`, + [ + input.accountId, + input.userId, + input.amount, + input.currency || 'USD', + input.destinationType, + input.destinationWalletId || null, + input.feeAmount || 0, + netAmount, + ] + ); + + return mapRowToWithdrawal(result.rows[0]); + } + + /** + * Find withdrawal by ID + */ + async findById(id: string): Promise { + const result = await db.query( + 'SELECT * FROM investment.withdrawal_requests WHERE id = $1', + [id] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToWithdrawal(result.rows[0]); + } + + /** + * Find withdrawals by user + */ + async findByUserId( + userId: string, + status?: WithdrawalStatus + ): Promise { + let query = `SELECT * FROM investment.withdrawal_requests WHERE user_id = $1`; + const params: (string | WithdrawalStatus)[] = [userId]; + + if (status) { + query += ` AND status = $2`; + params.push(status); + } + + query += ` ORDER BY created_at DESC`; + + const result = await db.query(query, params); + return result.rows.map(mapRowToWithdrawal); + } + + /** + * Find withdrawals by account + */ + async findByAccountId(accountId: string): Promise { + const result = await db.query( + `SELECT * FROM investment.withdrawal_requests + WHERE account_id = $1 + ORDER BY created_at DESC`, + [accountId] + ); + + return result.rows.map(mapRowToWithdrawal); + } + + /** + * Find all withdrawals with filters + */ + async findAll(filters: WithdrawalFilters = {}): Promise<{ + withdrawals: WithdrawalRequest[]; + total: number; + }> { + const conditions: string[] = []; + const values: (string | Date | number)[] = []; + let paramIndex = 1; + + if (filters.accountId) { + conditions.push(`account_id = $${paramIndex++}`); + values.push(filters.accountId); + } + + if (filters.userId) { + conditions.push(`user_id = $${paramIndex++}`); + values.push(filters.userId); + } + + if (filters.status) { + conditions.push(`status = $${paramIndex++}`); + values.push(filters.status); + } + + if (filters.startDate) { + conditions.push(`created_at >= $${paramIndex++}`); + values.push(filters.startDate); + } + + if (filters.endDate) { + conditions.push(`created_at <= $${paramIndex++}`); + values.push(filters.endDate); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Get total count + const countResult = await db.query<{ count: string }>( + `SELECT COUNT(*) as count FROM investment.withdrawal_requests ${whereClause}`, + values + ); + const total = parseInt(countResult.rows[0].count); + + // Get withdrawals + let query = ` + SELECT * FROM investment.withdrawal_requests + ${whereClause} + ORDER BY created_at DESC + `; + + if (filters.limit) { + query += ` LIMIT $${paramIndex++}`; + values.push(filters.limit); + } + + if (filters.offset) { + query += ` OFFSET $${paramIndex++}`; + values.push(filters.offset); + } + + const result = await db.query(query, values); + + return { + withdrawals: result.rows.map(mapRowToWithdrawal), + total, + }; + } + + /** + * Update withdrawal status to processing + */ + async process(id: string, processedBy: string): Promise { + const result = await db.query( + `UPDATE investment.withdrawal_requests + SET status = 'processing', processed_at = NOW(), processed_by = $2, updated_at = NOW() + WHERE id = $1 AND status = 'pending' + RETURNING *`, + [id, processedBy] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToWithdrawal(result.rows[0]); + } + + /** + * Complete a withdrawal + */ + async complete(id: string, transactionId?: string): Promise { + const result = await db.query( + `UPDATE investment.withdrawal_requests + SET status = 'completed', transaction_id = $2, updated_at = NOW() + WHERE id = $1 AND status = 'processing' + RETURNING *`, + [id, transactionId || null] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToWithdrawal(result.rows[0]); + } + + /** + * Reject a withdrawal + */ + async reject( + id: string, + rejectionReason: string, + processedBy: string + ): Promise { + const result = await db.query( + `UPDATE investment.withdrawal_requests + SET status = 'rejected', rejection_reason = $2, processed_by = $3, + processed_at = NOW(), updated_at = NOW() + WHERE id = $1 AND status IN ('pending', 'processing') + RETURNING *`, + [id, rejectionReason, processedBy] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToWithdrawal(result.rows[0]); + } + + /** + * Get daily withdrawal total for a user + */ + async getDailyTotal(userId: string): Promise { + const result = await db.query<{ total: string }>( + `SELECT COALESCE(SUM(amount), 0) as total + FROM investment.withdrawal_requests + WHERE user_id = $1 + AND status NOT IN ('rejected') + AND created_at >= CURRENT_DATE`, + [userId] + ); + + return parseFloat(result.rows[0].total); + } + + /** + * Get pending withdrawals count + */ + async getPendingCount(): Promise { + const result = await db.query<{ count: string }>( + `SELECT COUNT(*) as count FROM investment.withdrawal_requests WHERE status = 'pending'` + ); + + return parseInt(result.rows[0].count); + } +} + +// Export singleton instance +export const withdrawalRepository = new WithdrawalRepository(); diff --git a/src/modules/investment/services/product.service.ts b/src/modules/investment/services/product.service.ts index 60da908..f9ac004 100644 --- a/src/modules/investment/services/product.service.ts +++ b/src/modules/investment/services/product.service.ts @@ -1,9 +1,16 @@ /** * Investment Product Service * Manages investment products (Atlas, Orion, Nova) + * + * Supports both PostgreSQL repository and in-memory fallback for defaults */ import { v4 as uuidv4 } from 'uuid'; +import { + productRepository, + InvestmentProduct as RepoProduct, +} from '../repositories/product.repository'; +import { RiskProfile as RepoRiskProfile } from '../repositories/account.repository'; // ============================================================================ // Types @@ -32,7 +39,33 @@ export interface InvestmentProduct { } // ============================================================================ -// Default Products +// Helper Functions +// ============================================================================ + +function mapRepoToService(repo: RepoProduct): InvestmentProduct { + return { + id: repo.id, + code: repo.slug, + name: repo.name, + description: repo.description || '', + riskProfile: repo.riskProfile as RiskProfile, + targetReturnMin: repo.targetMonthlyReturn || 0, + targetReturnMax: (repo.targetMonthlyReturn || 0) * 1.5, + maxDrawdown: repo.maxDrawdown || 10, + minInvestment: repo.minInvestment, + managementFee: repo.managementFeePercent, + performanceFee: repo.performanceFeePercent, + isActive: repo.isActive, + features: [], // Features stored in description + strategy: repo.shortDescription || '', + assets: [], // Assets from product type + tradingFrequency: '', + createdAt: repo.createdAt, + }; +} + +// ============================================================================ +// Default Products (fallback when DB is empty) // ============================================================================ const DEFAULT_PRODUCTS: InvestmentProduct[] = [ @@ -113,11 +146,8 @@ const DEFAULT_PRODUCTS: InvestmentProduct[] = [ }, ]; -// ============================================================================ -// In-Memory Storage -// ============================================================================ - -const products: Map = new Map( +// In-memory fallback storage +const defaultProducts: Map = new Map( DEFAULT_PRODUCTS.map((p) => [p.id, p]) ); @@ -126,32 +156,80 @@ const products: Map = new Map( // ============================================================================ class ProductService { + private useDatabase = true; // Toggle for DB vs in-memory + /** * Get all active products */ async getProducts(): Promise { - return Array.from(products.values()).filter((p) => p.isActive); + if (this.useDatabase) { + try { + const repoProducts = await productRepository.findAllVisible(); + if (repoProducts.length > 0) { + return repoProducts.map(mapRepoToService); + } + } catch { + // Fall back to in-memory on DB error + } + } + + return Array.from(defaultProducts.values()).filter((p) => p.isActive); } /** * Get product by ID */ async getProductById(id: string): Promise { - return products.get(id) || null; + if (this.useDatabase) { + try { + const repo = await productRepository.findById(id); + if (repo) { + return mapRepoToService(repo); + } + } catch { + // Fall back to in-memory on DB error + } + } + + return defaultProducts.get(id) || null; } /** - * Get product by code + * Get product by code (slug) */ async getProductByCode(code: string): Promise { - return Array.from(products.values()).find((p) => p.code === code) || null; + if (this.useDatabase) { + try { + const repo = await productRepository.findBySlug(code); + if (repo) { + return mapRepoToService(repo); + } + } catch { + // Fall back to in-memory on DB error + } + } + + return Array.from(defaultProducts.values()).find((p) => p.code === code) || null; } /** * Get products by risk profile */ async getProductsByRiskProfile(riskProfile: RiskProfile): Promise { - return Array.from(products.values()).filter( + if (this.useDatabase) { + try { + const repoProducts = await productRepository.findByRiskProfile( + riskProfile as RepoRiskProfile + ); + if (repoProducts.length > 0) { + return repoProducts.map(mapRepoToService); + } + } catch { + // Fall back to in-memory on DB error + } + } + + return Array.from(defaultProducts.values()).filter( (p) => p.riskProfile === riskProfile && p.isActive ); } @@ -162,12 +240,33 @@ class ProductService { async createProduct( input: Omit ): Promise { + if (this.useDatabase) { + try { + const repo = await productRepository.create({ + name: input.name, + slug: input.code, + description: input.description, + shortDescription: input.strategy, + productType: 'variable_return', + riskProfile: input.riskProfile as RepoRiskProfile, + targetMonthlyReturn: input.targetReturnMin, + maxDrawdown: input.maxDrawdown, + managementFeePercent: input.managementFee, + performanceFeePercent: input.performanceFee, + minInvestment: input.minInvestment, + }); + return mapRepoToService(repo); + } catch { + // Fall back to in-memory on DB error + } + } + const product: InvestmentProduct = { ...input, id: uuidv4(), createdAt: new Date(), }; - products.set(product.id, product); + defaultProducts.set(product.id, product); return product; } @@ -178,11 +277,32 @@ class ProductService { id: string, updates: Partial> ): Promise { - const product = products.get(id); + if (this.useDatabase) { + try { + const repo = await productRepository.update(id, { + name: updates.name, + description: updates.description, + shortDescription: updates.strategy, + targetMonthlyReturn: updates.targetReturnMin, + maxDrawdown: updates.maxDrawdown, + managementFeePercent: updates.managementFee, + performanceFeePercent: updates.performanceFee, + minInvestment: updates.minInvestment, + isActive: updates.isActive, + }); + if (repo) { + return mapRepoToService(repo); + } + } catch { + // Fall back to in-memory on DB error + } + } + + const product = defaultProducts.get(id); if (!product) return null; const updated = { ...product, ...updates }; - products.set(id, updated); + defaultProducts.set(id, updated); return updated; } @@ -190,7 +310,18 @@ class ProductService { * Deactivate a product (admin only) */ async deactivateProduct(id: string): Promise { - const product = products.get(id); + if (this.useDatabase) { + try { + const repo = await productRepository.deactivate(id); + if (repo) { + return true; + } + } catch { + // Fall back to in-memory on DB error + } + } + + const product = defaultProducts.get(id); if (!product) return false; product.isActive = false; @@ -206,7 +337,7 @@ class ProductService { avgReturn: number; winRate: number; }> { - // TODO: Calculate from real data + // TODO: Calculate from real data using account repository return { totalInvestors: Math.floor(Math.random() * 1000) + 100, totalAum: Math.floor(Math.random() * 10000000) + 1000000, @@ -219,9 +350,10 @@ class ProductService { * Get product performance history */ async getProductPerformance( - productId: string, + _productId: string, period: 'week' | 'month' | '3months' | 'year' ): Promise<{ date: string; return: number }[]> { + // TODO: Calculate from real performance data const days = period === 'week' ? 7 : period === 'month' ? 30 : period === '3months' ? 90 : 365; diff --git a/src/modules/investment/services/transaction.service.ts b/src/modules/investment/services/transaction.service.ts index b140a52..93a39b4 100644 --- a/src/modules/investment/services/transaction.service.ts +++ b/src/modules/investment/services/transaction.service.ts @@ -5,7 +5,6 @@ * Now uses PostgreSQL repository for transactions */ -import { v4 as uuidv4 } from 'uuid'; import { accountService } from './account.service'; import { transactionRepository, @@ -13,6 +12,16 @@ import { 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 @@ -130,13 +139,44 @@ export interface CreateWithdrawalInput { } // ============================================================================ -// In-Memory Storage (for entities not yet migrated to PostgreSQL) +// Helper Functions for Withdrawal Mapping // ============================================================================ -// Note: Transactions now use PostgreSQL via transactionRepository -// Withdrawal requests and distributions will use in-memory until their repositories are created -const withdrawalRequests: Map = new Map(); -const distributions: Map = new Map(); +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 @@ -293,22 +333,22 @@ class TransactionService { userId: string, status?: WithdrawalStatus ): Promise { - let userWithdrawals = Array.from(withdrawalRequests.values()) - .filter((w) => w.userId === userId) - .sort((a, b) => b.requestedAt.getTime() - a.requestedAt.getTime()); + const repoWithdrawals = await withdrawalRepository.findByUserId( + userId, + status as RepoWithdrawalStatus | undefined + ); - if (status) { - userWithdrawals = userWithdrawals.filter((w) => w.status === status); - } - - return userWithdrawals; + return repoWithdrawals.map(mapRepoWithdrawalToService); } /** * Get withdrawal request by ID */ async getWithdrawalById(withdrawalId: string): Promise { - return withdrawalRequests.get(withdrawalId) || null; + const repo = await withdrawalRepository.findById(withdrawalId); + if (!repo) return null; + + return mapRepoWithdrawalToService(repo); } /** @@ -343,39 +383,27 @@ class TransactionService { throw new Error('Bank or crypto information is required'); } - // Check daily withdrawal limit + // Check daily withdrawal limit using repository const dailyLimit = 10000; - const todayWithdrawals = Array.from(withdrawalRequests.values()) - .filter((w) => { - const today = new Date(); - today.setHours(0, 0, 0, 0); - return ( - w.userId === userId && - w.status !== 'rejected' && - w.requestedAt >= today - ); - }) - .reduce((sum, w) => sum + w.amount, 0); + const todayWithdrawals = await withdrawalRepository.getDailyTotal(userId); if (todayWithdrawals + input.amount > dailyLimit) { throw new Error(`Daily withdrawal limit of $${dailyLimit} exceeded`); } - const withdrawal: WithdrawalRequest = { - id: uuidv4(), + // Create withdrawal request in database + const destinationType = input.bankInfo ? 'bank_transfer' : 'wallet'; + const repo = await withdrawalRepository.create({ accountId: input.accountId, userId, amount: input.amount, - status: 'pending', - bankInfo: input.bankInfo || null, - cryptoInfo: input.cryptoInfo || null, - rejectionReason: null, - requestedAt: new Date(), - processedAt: null, - completedAt: null, - }; + destinationType, + }); - withdrawalRequests.set(withdrawal.id, withdrawal); + 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({ @@ -393,59 +421,63 @@ class TransactionService { /** * Process a withdrawal (admin) */ - async processWithdrawal(withdrawalId: string): Promise { - const withdrawal = withdrawalRequests.get(withdrawalId); - if (!withdrawal) { + async processWithdrawal(withdrawalId: string, processedBy: string = 'system'): Promise { + const repo = await withdrawalRepository.findById(withdrawalId); + if (!repo) { throw new Error(`Withdrawal not found: ${withdrawalId}`); } - if (withdrawal.status !== 'pending') { + if (repo.status !== 'pending') { throw new Error('Withdrawal is not pending'); } - withdrawal.status = 'processing'; - withdrawal.processedAt = new Date(); + const updated = await withdrawalRepository.process(withdrawalId, processedBy); + if (!updated) { + throw new Error('Failed to process withdrawal'); + } - return withdrawal; + return mapRepoWithdrawalToService(updated); } /** * Complete a withdrawal (admin) */ async completeWithdrawal(withdrawalId: string): Promise { - const withdrawal = withdrawalRequests.get(withdrawalId); - if (!withdrawal) { + const repo = await withdrawalRepository.findById(withdrawalId); + if (!repo) { throw new Error(`Withdrawal not found: ${withdrawalId}`); } - if (withdrawal.status !== 'processing') { + if (repo.status !== 'processing') { throw new Error('Withdrawal is not being processed'); } // Deduct from account using accountService.withdraw - const updatedAccount = await accountService.withdraw(withdrawal.accountId, withdrawal.amount); + const updatedAccount = await accountService.withdraw(repo.accountId, repo.amount); - // Update withdrawal - withdrawal.status = 'completed'; - withdrawal.completedAt = new Date(); + // 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( - withdrawal.accountId, + repo.accountId, { transactionType: 'withdrawal', status: 'pending' } ); - const pendingTx = pendingTxs.find((t) => t.amount === withdrawal.amount); + 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 $${withdrawal.amount.toFixed(2)}`, + notes: `Withdrawal of $${repo.amount.toFixed(2)}`, }); } - return withdrawal; + return mapRepoWithdrawalToService(completed); } /** @@ -453,28 +485,31 @@ class TransactionService { */ async rejectWithdrawal( withdrawalId: string, - reason: string + reason: string, + processedBy: string = 'system' ): Promise { - const withdrawal = withdrawalRequests.get(withdrawalId); - if (!withdrawal) { + const repo = await withdrawalRepository.findById(withdrawalId); + if (!repo) { throw new Error(`Withdrawal not found: ${withdrawalId}`); } - if (withdrawal.status === 'completed') { + if (repo.status === 'completed') { throw new Error('Cannot reject completed withdrawal'); } - withdrawal.status = 'rejected'; - withdrawal.rejectionReason = reason; - withdrawal.processedAt = new Date(); + // 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( - withdrawal.accountId, + repo.accountId, { transactionType: 'withdrawal', status: 'pending' } ); - const pendingTx = pendingTxs.find((t) => t.amount === withdrawal.amount); + const pendingTx = pendingTxs.find((t) => t.amount === repo.amount); if (pendingTx) { await transactionRepository.updateStatus(pendingTx.id, 'cancelled', { failureReason: reason, @@ -484,7 +519,7 @@ class TransactionService { }); } - return withdrawal; + return mapRepoWithdrawalToService(rejected); } // ========================================================================== @@ -495,9 +530,11 @@ class TransactionService { * Get distributions for an account */ async getAccountDistributions(accountId: string): Promise { - return Array.from(distributions.values()) - .filter((d) => d.accountId === accountId) - .sort((a, b) => b.periodEnd.getTime() - a.periodEnd.getTime()); + const account = await accountService.getAccountById(accountId); + const userId = account?.userId || ''; + + const repoDistributions = await distributionRepository.findByAccountId(accountId); + return repoDistributions.map((d) => mapRepoDistributionToService(d, userId)); } /** @@ -515,82 +552,105 @@ class TransactionService { throw new Error(`Account not found: ${accountId}`); } - const performanceFee = grossEarnings * (performanceFeePercent / 100); - const netEarnings = grossEarnings - performanceFee; + // Platform takes performance fee, client gets the rest + const platformSharePercent = performanceFeePercent; + const clientSharePercent = 100 - performanceFeePercent; - const distribution: Distribution = { - id: uuidv4(), + const repo = await distributionRepository.create({ accountId, - userId: account.userId, periodStart, periodEnd, - grossEarnings, - performanceFee, - netEarnings, - status: 'pending', - distributedAt: null, - createdAt: new Date(), - }; + grossProfit: grossEarnings, + managementFee: 0, + platformSharePercent, + clientSharePercent, + }); - distributions.set(distribution.id, distribution); - return distribution; + return mapRepoDistributionToService(repo, account.userId); } /** * Distribute earnings */ async distributeEarnings(distributionId: string): Promise { - const distribution = distributions.get(distributionId); - if (!distribution) { + const repo = await distributionRepository.findById(distributionId); + if (!repo) { throw new Error(`Distribution not found: ${distributionId}`); } - if (distribution.status === 'distributed') { + if (repo.status === 'distributed') { throw new Error('Distribution already completed'); } - // Get account balance before earnings - const accountBefore = await accountService.getAccountById(distribution.accountId); - const balanceBefore = accountBefore?.balance || 0; + const account = await accountService.getAccountById(repo.accountId); + if (!account) { + throw new Error(`Account not found: ${repo.accountId}`); + } - // Record earnings in account + // Get account balance before earnings + const balanceBefore = account.balance; + + // Record earnings in account (client amount) await accountService.recordEarnings( - distribution.accountId, - distribution.grossEarnings, - distribution.performanceFee + repo.accountId, + repo.grossProfit, + repo.platformAmount ); // Get updated account - const account = await accountService.getAccountById(distribution.accountId); + const updatedAccount = await accountService.getAccountById(repo.accountId); - // Create distribution transaction in database (net earnings) - if (distribution.netEarnings !== 0) { + // Create distribution transaction in database (client earnings) + if (repo.clientAmount !== 0) { const earningTx = await transactionRepository.create({ - accountId: distribution.accountId, + accountId: repo.accountId, transactionType: 'distribution', - amount: distribution.netEarnings, + amount: repo.clientAmount, balanceBefore, - balanceAfter: account?.balance, + balanceAfter: updatedAccount?.balance, distributionId, - notes: `Earnings for ${distribution.periodStart.toLocaleDateString()} - ${distribution.periodEnd.toLocaleDateString()}`, + notes: `Earnings for ${repo.periodStart.toLocaleDateString()} - ${repo.periodEnd.toLocaleDateString()}`, }); // Mark as completed immediately await transactionRepository.updateStatus(earningTx.id, 'completed'); } - distribution.status = 'distributed'; - distribution.distributedAt = new Date(); + // Mark distribution as distributed + const distributed = await distributionRepository.distribute(distributionId); + if (!distributed) { + throw new Error('Failed to mark distribution as distributed'); + } - return distribution; + return mapRepoDistributionToService(distributed, account.userId); } /** * Get pending distributions */ async getPendingDistributions(): Promise { - return Array.from(distributions.values()) - .filter((d) => d.status === 'pending') - .sort((a, b) => a.periodEnd.getTime() - b.periodEnd.getTime()); + 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(); } }