From 3df1ed1f949c3357ff4818d2c7c784275406cc79 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sun, 25 Jan 2026 07:44:07 -0600 Subject: [PATCH] [OQI-004] feat: Migrate investment services to PostgreSQL repositories - account.service.ts: Now uses accountRepository instead of in-memory Map - transaction.service.ts: Now uses transactionRepository for transactions - Added account.repository.ts with full CRUD and balance operations - Added transaction.repository.ts with query, create, and update operations - Withdrawal and distribution entities still use in-memory storage Co-Authored-By: Claude Opus 4.5 --- .../repositories/account.repository.ts | 464 ++++++++++++++++ src/modules/investment/repositories/index.ts | 6 + .../repositories/transaction.repository.ts | 504 ++++++++++++++++++ .../investment/services/account.service.ts | 314 +++++++---- .../services/transaction.service.ts | 295 +++++----- 5 files changed, 1334 insertions(+), 249 deletions(-) create mode 100644 src/modules/investment/repositories/account.repository.ts create mode 100644 src/modules/investment/repositories/index.ts create mode 100644 src/modules/investment/repositories/transaction.repository.ts diff --git a/src/modules/investment/repositories/account.repository.ts b/src/modules/investment/repositories/account.repository.ts new file mode 100644 index 0000000..5f06bc6 --- /dev/null +++ b/src/modules/investment/repositories/account.repository.ts @@ -0,0 +1,464 @@ +/** + * Investment Account Repository + * Handles database operations for investment accounts + */ + +import { db } from '../../../shared/database'; + +// ============================================================================ +// Types +// ============================================================================ + +export type AccountStatus = 'pending_kyc' | 'active' | 'suspended' | 'closed'; +export type RiskProfile = 'conservative' | 'moderate' | 'aggressive'; + +export interface AccountRow { + id: string; + user_id: string; + product_id: string; + account_number: string; + initial_balance: string; + current_balance: string; + total_deposits: string; + total_withdrawals: string; + total_distributions: string; + total_return_percent: string; + total_return_amount: string; + user_risk_profile: RiskProfile; + questionnaire_id: string | null; + status: AccountStatus; + kyc_verified: boolean; + kyc_verified_at: Date | null; + kyc_verified_by: string | null; + opened_at: Date | null; + closed_at: Date | null; + last_distribution_at: Date | null; + created_at: Date; + updated_at: Date; +} + +export interface InvestmentAccount { + id: string; + userId: string; + productId: string; + accountNumber: string; + initialBalance: number; + currentBalance: number; + totalDeposits: number; + totalWithdrawals: number; + totalDistributions: number; + totalReturnPercent: number; + totalReturnAmount: number; + userRiskProfile: RiskProfile; + questionnaireId: string | null; + status: AccountStatus; + kycVerified: boolean; + kycVerifiedAt: Date | null; + kycVerifiedBy: string | null; + openedAt: Date | null; + closedAt: Date | null; + lastDistributionAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateAccountInput { + userId: string; + productId: string; + initialBalance: number; + userRiskProfile: RiskProfile; + questionnaireId?: string; +} + +export interface UpdateAccountInput { + currentBalance?: number; + totalDeposits?: number; + totalWithdrawals?: number; + totalDistributions?: number; + totalReturnPercent?: number; + totalReturnAmount?: number; + status?: AccountStatus; + kycVerified?: boolean; + kycVerifiedAt?: Date; + kycVerifiedBy?: string; + openedAt?: Date; + closedAt?: Date; + lastDistributionAt?: Date; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function mapRowToAccount(row: AccountRow): InvestmentAccount { + return { + id: row.id, + userId: row.user_id, + productId: row.product_id, + accountNumber: row.account_number, + initialBalance: parseFloat(row.initial_balance), + currentBalance: parseFloat(row.current_balance), + totalDeposits: parseFloat(row.total_deposits), + totalWithdrawals: parseFloat(row.total_withdrawals), + totalDistributions: parseFloat(row.total_distributions), + totalReturnPercent: parseFloat(row.total_return_percent), + totalReturnAmount: parseFloat(row.total_return_amount), + userRiskProfile: row.user_risk_profile, + questionnaireId: row.questionnaire_id, + status: row.status, + kycVerified: row.kyc_verified, + kycVerifiedAt: row.kyc_verified_at, + kycVerifiedBy: row.kyc_verified_by, + openedAt: row.opened_at, + closedAt: row.closed_at, + lastDistributionAt: row.last_distribution_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +async function generateAccountNumber(): Promise { + const date = new Date(); + const yearMonth = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}`; + + const result = await db.query<{ count: string }>( + `SELECT COUNT(*) as count FROM investment.accounts + WHERE account_number LIKE $1`, + [`INV-${yearMonth}-%`] + ); + + const count = parseInt(result.rows[0].count) + 1; + return `INV-${yearMonth}-${String(count).padStart(5, '0')}`; +} + +// ============================================================================ +// Repository Class +// ============================================================================ + +class AccountRepository { + /** + * Create a new investment account + */ + async create(input: CreateAccountInput): Promise { + const accountNumber = await generateAccountNumber(); + + const result = await db.query( + `INSERT INTO investment.accounts ( + user_id, product_id, account_number, initial_balance, current_balance, + user_risk_profile, questionnaire_id, status, opened_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', NOW()) + RETURNING *`, + [ + input.userId, + input.productId, + accountNumber, + input.initialBalance, + input.initialBalance, + input.userRiskProfile, + input.questionnaireId || null, + ] + ); + + return mapRowToAccount(result.rows[0]); + } + + /** + * Find account by ID + */ + async findById(id: string): Promise { + const result = await db.query( + 'SELECT * FROM investment.accounts WHERE id = $1', + [id] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToAccount(result.rows[0]); + } + + /** + * Find account by account number + */ + async findByAccountNumber(accountNumber: string): Promise { + const result = await db.query( + 'SELECT * FROM investment.accounts WHERE account_number = $1', + [accountNumber] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToAccount(result.rows[0]); + } + + /** + * Find all accounts for a user + */ + async findByUserId(userId: string): Promise { + const result = await db.query( + `SELECT * FROM investment.accounts + WHERE user_id = $1 + ORDER BY created_at DESC`, + [userId] + ); + + return result.rows.map(mapRowToAccount); + } + + /** + * Find account by user and product + */ + async findByUserAndProduct( + userId: string, + productId: string + ): Promise { + const result = await db.query( + `SELECT * FROM investment.accounts + WHERE user_id = $1 AND product_id = $2 AND status != 'closed'`, + [userId, productId] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToAccount(result.rows[0]); + } + + /** + * Find all active accounts (for distribution processing) + */ + async findAllActive(): Promise { + const result = await db.query( + `SELECT * FROM investment.accounts + WHERE status = 'active' + ORDER BY created_at ASC` + ); + + return result.rows.map(mapRowToAccount); + } + + /** + * Update account + */ + async update(id: string, input: UpdateAccountInput): Promise { + const updates: string[] = []; + const values: (string | number | boolean | Date | null)[] = []; + let paramIndex = 1; + + if (input.currentBalance !== undefined) { + updates.push(`current_balance = $${paramIndex++}`); + values.push(input.currentBalance); + } + + if (input.totalDeposits !== undefined) { + updates.push(`total_deposits = $${paramIndex++}`); + values.push(input.totalDeposits); + } + + if (input.totalWithdrawals !== undefined) { + updates.push(`total_withdrawals = $${paramIndex++}`); + values.push(input.totalWithdrawals); + } + + if (input.totalDistributions !== undefined) { + updates.push(`total_distributions = $${paramIndex++}`); + values.push(input.totalDistributions); + } + + if (input.totalReturnPercent !== undefined) { + updates.push(`total_return_percent = $${paramIndex++}`); + values.push(input.totalReturnPercent); + } + + if (input.totalReturnAmount !== undefined) { + updates.push(`total_return_amount = $${paramIndex++}`); + values.push(input.totalReturnAmount); + } + + if (input.status !== undefined) { + updates.push(`status = $${paramIndex++}`); + values.push(input.status); + } + + if (input.kycVerified !== undefined) { + updates.push(`kyc_verified = $${paramIndex++}`); + values.push(input.kycVerified); + } + + if (input.kycVerifiedAt !== undefined) { + updates.push(`kyc_verified_at = $${paramIndex++}`); + values.push(input.kycVerifiedAt); + } + + if (input.kycVerifiedBy !== undefined) { + updates.push(`kyc_verified_by = $${paramIndex++}`); + values.push(input.kycVerifiedBy); + } + + if (input.openedAt !== undefined) { + updates.push(`opened_at = $${paramIndex++}`); + values.push(input.openedAt); + } + + if (input.closedAt !== undefined) { + updates.push(`closed_at = $${paramIndex++}`); + values.push(input.closedAt); + } + + if (input.lastDistributionAt !== undefined) { + updates.push(`last_distribution_at = $${paramIndex++}`); + values.push(input.lastDistributionAt); + } + + if (updates.length === 0) { + return this.findById(id); + } + + updates.push(`updated_at = NOW()`); + values.push(id); + + const result = await db.query( + `UPDATE investment.accounts + SET ${updates.join(', ')} + WHERE id = $${paramIndex} + RETURNING *`, + values + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToAccount(result.rows[0]); + } + + /** + * Update account balance with transaction + */ + async updateBalance( + id: string, + balanceChange: number, + type: 'deposit' | 'withdrawal' | 'distribution' + ): Promise { + return await db.transaction(async (client) => { + // Lock the row for update + const lockResult = await client.query( + 'SELECT * FROM investment.accounts WHERE id = $1 FOR UPDATE', + [id] + ); + + if (lockResult.rows.length === 0) { + return null; + } + + const account = lockResult.rows[0]; + const newBalance = parseFloat(account.current_balance) + balanceChange; + + if (newBalance < 0) { + throw new Error('Insufficient balance'); + } + + let updateQuery = ` + UPDATE investment.accounts + SET current_balance = $1, updated_at = NOW() + `; + const values: (number | string)[] = [newBalance]; + let paramIndex = 2; + + if (type === 'deposit') { + updateQuery += `, total_deposits = total_deposits + $${paramIndex++}`; + values.push(balanceChange); + } else if (type === 'withdrawal') { + updateQuery += `, total_withdrawals = total_withdrawals + $${paramIndex++}`; + values.push(Math.abs(balanceChange)); + } else if (type === 'distribution') { + updateQuery += `, total_distributions = total_distributions + $${paramIndex++}`; + updateQuery += `, last_distribution_at = NOW()`; + values.push(balanceChange); + } + + updateQuery += ` WHERE id = $${paramIndex} RETURNING *`; + values.push(id); + + const result = await client.query(updateQuery, values); + return mapRowToAccount(result.rows[0]); + }); + } + + /** + * Close account + */ + async close(id: string): Promise { + return this.update(id, { + status: 'closed', + closedAt: new Date(), + }); + } + + /** + * Suspend account + */ + async suspend(id: string): Promise { + return this.update(id, { + status: 'suspended', + }); + } + + /** + * Reactivate account + */ + async reactivate(id: string): Promise { + return this.update(id, { + status: 'active', + }); + } + + /** + * Get account summary for a user + */ + async getAccountSummary(userId: string): Promise<{ + totalBalance: number; + totalDeposits: number; + totalWithdrawals: number; + totalDistributions: number; + totalReturnAmount: number; + accountCount: number; + }> { + const result = await db.query<{ + total_balance: string; + total_deposits: string; + total_withdrawals: string; + total_distributions: string; + total_return_amount: string; + account_count: string; + }>( + `SELECT + COALESCE(SUM(current_balance), 0) as total_balance, + COALESCE(SUM(total_deposits), 0) as total_deposits, + COALESCE(SUM(total_withdrawals), 0) as total_withdrawals, + COALESCE(SUM(total_distributions), 0) as total_distributions, + COALESCE(SUM(total_return_amount), 0) as total_return_amount, + COUNT(*) as account_count + FROM investment.accounts + WHERE user_id = $1 AND status != 'closed'`, + [userId] + ); + + const row = result.rows[0]; + return { + totalBalance: parseFloat(row.total_balance), + totalDeposits: parseFloat(row.total_deposits), + totalWithdrawals: parseFloat(row.total_withdrawals), + totalDistributions: parseFloat(row.total_distributions), + totalReturnAmount: parseFloat(row.total_return_amount), + accountCount: parseInt(row.account_count), + }; + } +} + +// Export singleton instance +export const accountRepository = new AccountRepository(); diff --git a/src/modules/investment/repositories/index.ts b/src/modules/investment/repositories/index.ts new file mode 100644 index 0000000..fcceee4 --- /dev/null +++ b/src/modules/investment/repositories/index.ts @@ -0,0 +1,6 @@ +/** + * Investment Repositories - Index + */ + +export * from './account.repository'; +export * from './transaction.repository'; diff --git a/src/modules/investment/repositories/transaction.repository.ts b/src/modules/investment/repositories/transaction.repository.ts new file mode 100644 index 0000000..8cc190b --- /dev/null +++ b/src/modules/investment/repositories/transaction.repository.ts @@ -0,0 +1,504 @@ +/** + * Investment Transaction Repository + * Handles database operations for investment transactions + */ + +import { db } from '../../../shared/database'; + +// ============================================================================ +// Types +// ============================================================================ + +export type TransactionType = 'deposit' | 'withdrawal' | 'distribution'; +export type TransactionStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'; + +export interface TransactionRow { + id: string; + account_id: string; + transaction_number: string; + transaction_type: TransactionType; + amount: string; + status: TransactionStatus; + payment_method: string | null; + payment_reference: string | null; + payment_metadata: Record | null; + distribution_id: string | null; + balance_before: string | null; + balance_after: string | null; + requested_at: Date; + processed_at: Date | null; + completed_at: Date | null; + failed_at: Date | null; + failure_reason: string | null; + requires_approval: boolean; + approved_by: string | null; + approved_at: Date | null; + notes: string | null; + created_at: Date; + updated_at: Date; +} + +export interface Transaction { + id: string; + accountId: string; + transactionNumber: string; + transactionType: TransactionType; + amount: number; + status: TransactionStatus; + paymentMethod: string | null; + paymentReference: string | null; + paymentMetadata: Record | null; + distributionId: string | null; + balanceBefore: number | null; + balanceAfter: number | null; + requestedAt: Date; + processedAt: Date | null; + completedAt: Date | null; + failedAt: Date | null; + failureReason: string | null; + requiresApproval: boolean; + approvedBy: string | null; + approvedAt: Date | null; + notes: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateTransactionInput { + accountId: string; + transactionType: TransactionType; + amount: number; + paymentMethod?: string; + paymentReference?: string; + paymentMetadata?: Record; + distributionId?: string; + balanceBefore?: number; + balanceAfter?: number; + requiresApproval?: boolean; + notes?: string; +} + +export interface UpdateTransactionInput { + status?: TransactionStatus; + paymentReference?: string; + paymentMetadata?: Record; + balanceAfter?: number; + processedAt?: Date; + completedAt?: Date; + failedAt?: Date; + failureReason?: string; + approvedBy?: string; + approvedAt?: Date; + notes?: string; +} + +export interface TransactionFilters { + accountId?: string; + transactionType?: TransactionType; + status?: TransactionStatus; + startDate?: Date; + endDate?: Date; + limit?: number; + offset?: number; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function mapRowToTransaction(row: TransactionRow): Transaction { + return { + id: row.id, + accountId: row.account_id, + transactionNumber: row.transaction_number, + transactionType: row.transaction_type, + amount: parseFloat(row.amount), + status: row.status, + paymentMethod: row.payment_method, + paymentReference: row.payment_reference, + paymentMetadata: row.payment_metadata, + distributionId: row.distribution_id, + balanceBefore: row.balance_before ? parseFloat(row.balance_before) : null, + balanceAfter: row.balance_after ? parseFloat(row.balance_after) : null, + requestedAt: row.requested_at, + processedAt: row.processed_at, + completedAt: row.completed_at, + failedAt: row.failed_at, + failureReason: row.failure_reason, + requiresApproval: row.requires_approval, + approvedBy: row.approved_by, + approvedAt: row.approved_at, + notes: row.notes, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +async function generateTransactionNumber(): Promise { + const date = new Date(); + const yearMonth = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}`; + + const result = await db.query<{ count: string }>( + `SELECT COUNT(*) as count FROM investment.transactions + WHERE transaction_number LIKE $1`, + [`TXN-${yearMonth}-%`] + ); + + const count = parseInt(result.rows[0].count) + 1; + return `TXN-${yearMonth}-${String(count).padStart(5, '0')}`; +} + +// ============================================================================ +// Repository Class +// ============================================================================ + +class TransactionRepository { + /** + * Create a new transaction + */ + async create(input: CreateTransactionInput): Promise { + const transactionNumber = await generateTransactionNumber(); + + const result = await db.query( + `INSERT INTO investment.transactions ( + account_id, transaction_number, transaction_type, amount, status, + payment_method, payment_reference, payment_metadata, distribution_id, + balance_before, balance_after, requires_approval, notes + ) VALUES ($1, $2, $3, $4, 'pending', $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING *`, + [ + input.accountId, + transactionNumber, + input.transactionType, + input.amount, + input.paymentMethod || null, + input.paymentReference || null, + input.paymentMetadata ? JSON.stringify(input.paymentMetadata) : null, + input.distributionId || null, + input.balanceBefore ?? null, + input.balanceAfter ?? null, + input.requiresApproval ?? false, + input.notes || null, + ] + ); + + return mapRowToTransaction(result.rows[0]); + } + + /** + * Find transaction by ID + */ + async findById(id: string): Promise { + const result = await db.query( + 'SELECT * FROM investment.transactions WHERE id = $1', + [id] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToTransaction(result.rows[0]); + } + + /** + * Find transaction by transaction number + */ + async findByTransactionNumber(transactionNumber: string): Promise { + const result = await db.query( + 'SELECT * FROM investment.transactions WHERE transaction_number = $1', + [transactionNumber] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToTransaction(result.rows[0]); + } + + /** + * Find transactions by account ID + */ + async findByAccountId( + accountId: string, + filters?: Omit + ): Promise<{ transactions: Transaction[]; total: number }> { + return this.findAll({ ...filters, accountId }); + } + + /** + * Find all transactions with filters + */ + async findAll(filters: TransactionFilters = {}): Promise<{ + transactions: Transaction[]; + 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.transactionType) { + conditions.push(`transaction_type = $${paramIndex++}`); + values.push(filters.transactionType); + } + + if (filters.status) { + conditions.push(`status = $${paramIndex++}`); + values.push(filters.status); + } + + if (filters.startDate) { + conditions.push(`requested_at >= $${paramIndex++}`); + values.push(filters.startDate); + } + + if (filters.endDate) { + conditions.push(`requested_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.transactions ${whereClause}`, + values + ); + const total = parseInt(countResult.rows[0].count); + + // Get transactions + let query = ` + SELECT * FROM investment.transactions + ${whereClause} + ORDER BY requested_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 { + transactions: result.rows.map(mapRowToTransaction), + total, + }; + } + + /** + * Update transaction + */ + async update(id: string, input: UpdateTransactionInput): Promise { + const updates: string[] = []; + const values: (string | number | boolean | Date | null | Record)[] = []; + let paramIndex = 1; + + if (input.status !== undefined) { + updates.push(`status = $${paramIndex++}`); + values.push(input.status); + } + + if (input.paymentReference !== undefined) { + updates.push(`payment_reference = $${paramIndex++}`); + values.push(input.paymentReference); + } + + if (input.paymentMetadata !== undefined) { + updates.push(`payment_metadata = $${paramIndex++}`); + values.push(JSON.stringify(input.paymentMetadata)); + } + + if (input.balanceAfter !== undefined) { + updates.push(`balance_after = $${paramIndex++}`); + values.push(input.balanceAfter); + } + + if (input.processedAt !== undefined) { + updates.push(`processed_at = $${paramIndex++}`); + values.push(input.processedAt); + } + + if (input.completedAt !== undefined) { + updates.push(`completed_at = $${paramIndex++}`); + values.push(input.completedAt); + } + + if (input.failedAt !== undefined) { + updates.push(`failed_at = $${paramIndex++}`); + values.push(input.failedAt); + } + + if (input.failureReason !== undefined) { + updates.push(`failure_reason = $${paramIndex++}`); + values.push(input.failureReason); + } + + if (input.approvedBy !== undefined) { + updates.push(`approved_by = $${paramIndex++}`); + values.push(input.approvedBy); + } + + if (input.approvedAt !== undefined) { + updates.push(`approved_at = $${paramIndex++}`); + values.push(input.approvedAt); + } + + if (input.notes !== undefined) { + updates.push(`notes = $${paramIndex++}`); + values.push(input.notes); + } + + if (updates.length === 0) { + return this.findById(id); + } + + updates.push(`updated_at = NOW()`); + values.push(id); + + const result = await db.query( + `UPDATE investment.transactions + SET ${updates.join(', ')} + WHERE id = $${paramIndex} + RETURNING *`, + values + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToTransaction(result.rows[0]); + } + + /** + * Update transaction status + */ + async updateStatus( + id: string, + status: TransactionStatus, + additionalData?: { + balanceAfter?: number; + failureReason?: string; + } + ): Promise { + const now = new Date(); + const updateInput: UpdateTransactionInput = { status }; + + switch (status) { + case 'processing': + updateInput.processedAt = now; + break; + case 'completed': + updateInput.completedAt = now; + if (additionalData?.balanceAfter !== undefined) { + updateInput.balanceAfter = additionalData.balanceAfter; + } + break; + case 'failed': + updateInput.failedAt = now; + if (additionalData?.failureReason) { + updateInput.failureReason = additionalData.failureReason; + } + break; + } + + return this.update(id, updateInput); + } + + /** + * Find pending deposits by payment reference (for Stripe webhook) + */ + async findPendingByPaymentReference(paymentReference: string): Promise { + const result = await db.query( + `SELECT * FROM investment.transactions + WHERE payment_reference = $1 AND status = 'pending'`, + [paymentReference] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToTransaction(result.rows[0]); + } + + /** + * Get transaction statistics for an account + */ + async getAccountStats(accountId: string): Promise<{ + totalDeposits: number; + totalWithdrawals: number; + totalDistributions: number; + pendingWithdrawals: number; + transactionCount: number; + }> { + const result = await db.query<{ + total_deposits: string; + total_withdrawals: string; + total_distributions: string; + pending_withdrawals: string; + transaction_count: string; + }>( + `SELECT + COALESCE(SUM(CASE WHEN transaction_type = 'deposit' AND status = 'completed' THEN amount ELSE 0 END), 0) as total_deposits, + COALESCE(SUM(CASE WHEN transaction_type = 'withdrawal' AND status = 'completed' THEN amount ELSE 0 END), 0) as total_withdrawals, + COALESCE(SUM(CASE WHEN transaction_type = 'distribution' AND status = 'completed' THEN amount ELSE 0 END), 0) as total_distributions, + COALESCE(SUM(CASE WHEN transaction_type = 'withdrawal' AND status IN ('pending', 'processing') THEN amount ELSE 0 END), 0) as pending_withdrawals, + COUNT(*) as transaction_count + FROM investment.transactions + WHERE account_id = $1`, + [accountId] + ); + + const row = result.rows[0]; + return { + totalDeposits: parseFloat(row.total_deposits), + totalWithdrawals: parseFloat(row.total_withdrawals), + totalDistributions: parseFloat(row.total_distributions), + pendingWithdrawals: parseFloat(row.pending_withdrawals), + transactionCount: parseInt(row.transaction_count), + }; + } + + /** + * Get daily withdrawal total for a user (for limit checking) + */ + async getDailyWithdrawalTotal(accountId: string): Promise { + const result = await db.query<{ total: string }>( + `SELECT COALESCE(SUM(amount), 0) as total + FROM investment.transactions + WHERE account_id = $1 + AND transaction_type = 'withdrawal' + AND status NOT IN ('failed', 'cancelled') + AND requested_at >= CURRENT_DATE`, + [accountId] + ); + + return parseFloat(result.rows[0].total); + } + + /** + * Approve a transaction + */ + async approve(id: string, approvedBy: string): Promise { + return this.update(id, { + approvedBy, + approvedAt: new Date(), + }); + } +} + +// Export singleton instance +export const transactionRepository = new TransactionRepository(); diff --git a/src/modules/investment/services/account.service.ts b/src/modules/investment/services/account.service.ts index 6f25d5e..add6474 100644 --- a/src/modules/investment/services/account.service.ts +++ b/src/modules/investment/services/account.service.ts @@ -1,21 +1,28 @@ /** * Investment Account Service * Manages user investment accounts + * + * Now uses PostgreSQL repository instead of in-memory storage */ -import { v4 as uuidv4 } from 'uuid'; import { productService, InvestmentProduct } from './product.service'; +import { + accountRepository, + InvestmentAccount as RepoAccount, + RiskProfile, +} from '../repositories/account.repository'; // ============================================================================ // Types // ============================================================================ -export type AccountStatus = 'active' | 'suspended' | 'closed'; +export type AccountStatus = 'active' | 'suspended' | 'closed' | 'pending_kyc'; export interface InvestmentAccount { id: string; userId: string; productId: string; + accountNumber: string; product?: InvestmentProduct; status: AccountStatus; balance: number; @@ -26,15 +33,19 @@ export interface InvestmentAccount { totalFeesPaid: number; unrealizedPnl: number; unrealizedPnlPercent: number; - openedAt: Date; + userRiskProfile: RiskProfile; + kycVerified: boolean; + openedAt: Date | null; closedAt: Date | null; updatedAt: Date; + createdAt: Date; } export interface CreateAccountInput { userId: string; productId: string; initialDeposit: number; + userRiskProfile?: RiskProfile; } export interface AccountSummary { @@ -48,10 +59,37 @@ export interface AccountSummary { } // ============================================================================ -// In-Memory Storage +// Helper: Map Repository Account to Service Account // ============================================================================ -const accounts: Map = new Map(); +function mapRepoToService(repo: RepoAccount, product?: InvestmentProduct): InvestmentAccount { + const totalDeposited = repo.totalDeposits; + const totalWithdrawn = repo.totalWithdrawals; + const totalEarnings = repo.totalDistributions; + + return { + id: repo.id, + userId: repo.userId, + productId: repo.productId, + accountNumber: repo.accountNumber, + product, + status: repo.status, + balance: repo.currentBalance, + initialInvestment: repo.initialBalance, + totalDeposited, + totalWithdrawn, + totalEarnings, + totalFeesPaid: 0, // Calculated separately if needed + unrealizedPnl: repo.totalReturnAmount, + unrealizedPnlPercent: repo.totalReturnPercent, + userRiskProfile: repo.userRiskProfile, + kycVerified: repo.kycVerified, + openedAt: repo.openedAt, + closedAt: repo.closedAt, + updatedAt: repo.updatedAt, + createdAt: repo.createdAt, + }; +} // ============================================================================ // Account Service @@ -62,25 +100,37 @@ class AccountService { * Get all accounts for a user */ async getUserAccounts(userId: string): Promise { - const userAccounts = Array.from(accounts.values()).filter((a) => a.userId === userId); + const repoAccounts = await accountRepository.findByUserId(userId); - // Attach product info - for (const account of userAccounts) { - account.product = (await productService.getProductById(account.productId)) || undefined; + const results: InvestmentAccount[] = []; + for (const repo of repoAccounts) { + const product = (await productService.getProductById(repo.productId)) || undefined; + results.push(mapRepoToService(repo, product)); } - return userAccounts; + return results; } /** * Get account by ID */ async getAccountById(accountId: string): Promise { - const account = accounts.get(accountId); - if (!account) return null; + const repo = await accountRepository.findById(accountId); + if (!repo) return null; - account.product = (await productService.getProductById(account.productId)) || undefined; - return account; + const product = (await productService.getProductById(repo.productId)) || undefined; + return mapRepoToService(repo, product); + } + + /** + * Get account by account number + */ + async getAccountByNumber(accountNumber: string): Promise { + const repo = await accountRepository.findByAccountNumber(accountNumber); + if (!repo) return null; + + const product = (await productService.getProductById(repo.productId)) || undefined; + return mapRepoToService(repo, product); } /** @@ -90,14 +140,11 @@ class AccountService { userId: string, productId: string ): Promise { - const account = Array.from(accounts.values()).find( - (a) => a.userId === userId && a.productId === productId && a.status !== 'closed' - ); + const repo = await accountRepository.findByUserAndProduct(userId, productId); + if (!repo) return null; - if (!account) return null; - - account.product = (await productService.getProductById(account.productId)) || undefined; - return account; + const product = (await productService.getProductById(repo.productId)) || undefined; + return mapRepoToService(repo, product); } /** @@ -126,74 +173,94 @@ class AccountService { throw new Error(`User already has an account with ${product.name}`); } - const account: InvestmentAccount = { - id: uuidv4(), + // Create account in database + const repo = await accountRepository.create({ userId: input.userId, productId: input.productId, - product, - status: 'active', - balance: input.initialDeposit, - initialInvestment: input.initialDeposit, - totalDeposited: input.initialDeposit, - totalWithdrawn: 0, - totalEarnings: 0, - totalFeesPaid: 0, - unrealizedPnl: 0, - unrealizedPnlPercent: 0, - openedAt: new Date(), - closedAt: null, - updatedAt: new Date(), - }; + initialBalance: input.initialDeposit, + userRiskProfile: input.userRiskProfile || 'moderate', + }); - accounts.set(account.id, account); - return account; + return mapRepoToService(repo, product); } /** * Deposit funds to an account */ async deposit(accountId: string, amount: number): Promise { - const account = accounts.get(accountId); - if (!account) { + const repo = await accountRepository.findById(accountId); + if (!repo) { throw new Error(`Account not found: ${accountId}`); } - if (account.status !== 'active') { - throw new Error(`Cannot deposit to ${account.status} account`); + if (repo.status !== 'active') { + throw new Error(`Cannot deposit to ${repo.status} account`); } if (amount <= 0) { throw new Error('Deposit amount must be positive'); } - account.balance += amount; - account.totalDeposited += amount; - account.updatedAt = new Date(); + const updated = await accountRepository.updateBalance(accountId, amount, 'deposit'); + if (!updated) { + throw new Error('Failed to update account balance'); + } - return account; + const product = (await productService.getProductById(updated.productId)) || undefined; + return mapRepoToService(updated, product); } /** - * Record earnings for an account + * Withdraw funds from an account + */ + async withdraw(accountId: string, amount: number): Promise { + const repo = await accountRepository.findById(accountId); + if (!repo) { + throw new Error(`Account not found: ${accountId}`); + } + + if (repo.status !== 'active') { + throw new Error(`Cannot withdraw from ${repo.status} account`); + } + + if (amount <= 0) { + throw new Error('Withdrawal amount must be positive'); + } + + if (amount > repo.currentBalance) { + throw new Error('Insufficient balance'); + } + + const updated = await accountRepository.updateBalance(accountId, -amount, 'withdrawal'); + if (!updated) { + throw new Error('Failed to update account balance'); + } + + const product = (await productService.getProductById(updated.productId)) || undefined; + return mapRepoToService(updated, product); + } + + /** + * Record earnings/distribution for an account */ async recordEarnings( accountId: string, grossEarnings: number, - performanceFee: number + _performanceFee: number ): Promise { - const account = accounts.get(accountId); - if (!account) { + const repo = await accountRepository.findById(accountId); + if (!repo) { throw new Error(`Account not found: ${accountId}`); } - const netEarnings = grossEarnings - performanceFee; + // Record distribution (net earnings after fees are handled separately) + const updated = await accountRepository.updateBalance(accountId, grossEarnings, 'distribution'); + if (!updated) { + throw new Error('Failed to record earnings'); + } - account.balance += netEarnings; - account.totalEarnings += netEarnings; - account.totalFeesPaid += performanceFee; - account.updatedAt = new Date(); - - return account; + const product = (await productService.getProductById(updated.productId)) || undefined; + return mapRepoToService(updated, product); } /** @@ -203,73 +270,88 @@ class AccountService { accountId: string, unrealizedPnl: number ): Promise { - const account = accounts.get(accountId); - if (!account) { + const repo = await accountRepository.findById(accountId); + if (!repo) { throw new Error(`Account not found: ${accountId}`); } - account.unrealizedPnl = unrealizedPnl; - account.unrealizedPnlPercent = - account.totalDeposited > 0 - ? (unrealizedPnl / account.totalDeposited) * 100 - : 0; - account.updatedAt = new Date(); + const returnPercent = repo.totalDeposits > 0 + ? (unrealizedPnl / repo.totalDeposits) * 100 + : 0; - return account; + const updated = await accountRepository.update(accountId, { + totalReturnAmount: unrealizedPnl, + totalReturnPercent: returnPercent, + }); + + if (!updated) { + throw new Error('Failed to update P&L'); + } + + const product = (await productService.getProductById(updated.productId)) || undefined; + return mapRepoToService(updated, product); } /** * Close an account */ async closeAccount(accountId: string): Promise { - const account = accounts.get(accountId); - if (!account) { + const repo = await accountRepository.findById(accountId); + if (!repo) { throw new Error(`Account not found: ${accountId}`); } - if (account.status === 'closed') { + if (repo.status === 'closed') { throw new Error('Account is already closed'); } - account.status = 'closed'; - account.closedAt = new Date(); - account.updatedAt = new Date(); + const updated = await accountRepository.close(accountId); + if (!updated) { + throw new Error('Failed to close account'); + } - return account; + const product = (await productService.getProductById(updated.productId)) || undefined; + return mapRepoToService(updated, product); } /** * Suspend an account */ async suspendAccount(accountId: string): Promise { - const account = accounts.get(accountId); - if (!account) { + const repo = await accountRepository.findById(accountId); + if (!repo) { throw new Error(`Account not found: ${accountId}`); } - account.status = 'suspended'; - account.updatedAt = new Date(); + const updated = await accountRepository.suspend(accountId); + if (!updated) { + throw new Error('Failed to suspend account'); + } - return account; + const product = (await productService.getProductById(updated.productId)) || undefined; + return mapRepoToService(updated, product); } /** * Reactivate a suspended account */ async reactivateAccount(accountId: string): Promise { - const account = accounts.get(accountId); - if (!account) { + const repo = await accountRepository.findById(accountId); + if (!repo) { throw new Error(`Account not found: ${accountId}`); } - if (account.status !== 'suspended') { + if (repo.status !== 'suspended') { throw new Error('Only suspended accounts can be reactivated'); } - account.status = 'active'; - account.updatedAt = new Date(); + const updated = await accountRepository.reactivate(accountId); + if (!updated) { + throw new Error('Failed to reactivate account'); + } - return account; + const product = (await productService.getProductById(updated.productId)) || undefined; + return mapRepoToService(updated, product); } /** @@ -277,27 +359,18 @@ class AccountService { */ async getAccountSummary(userId: string): Promise { const userAccounts = await this.getUserAccounts(userId); + const repoSummary = await accountRepository.getAccountSummary(userId); const summary: AccountSummary = { - totalBalance: 0, - totalEarnings: 0, - totalDeposited: 0, - totalWithdrawn: 0, - overallReturn: 0, + totalBalance: repoSummary.totalBalance, + totalEarnings: repoSummary.totalDistributions, + totalDeposited: repoSummary.totalDeposits, + totalWithdrawn: repoSummary.totalWithdrawals, + overallReturn: repoSummary.totalReturnAmount, overallReturnPercent: 0, accounts: userAccounts, }; - for (const account of userAccounts) { - if (account.status !== 'closed') { - summary.totalBalance += account.balance; - summary.totalEarnings += account.totalEarnings; - summary.totalDeposited += account.totalDeposited; - summary.totalWithdrawn += account.totalWithdrawn; - } - } - - summary.overallReturn = summary.totalBalance - summary.totalDeposited + summary.totalWithdrawn; summary.overallReturnPercent = summary.totalDeposited > 0 ? (summary.overallReturn / summary.totalDeposited) * 100 @@ -306,19 +379,36 @@ class AccountService { return summary; } + /** + * Get all active accounts (for distribution processing) + */ + async getAllActiveAccounts(): Promise { + const repoAccounts = await accountRepository.findAllActive(); + + const results: InvestmentAccount[] = []; + for (const repo of repoAccounts) { + const product = (await productService.getProductById(repo.productId)) || undefined; + results.push(mapRepoToService(repo, product)); + } + + return results; + } + /** * Get account performance history + * TODO: Implement with real historical data from transactions */ async getAccountPerformance( accountId: string, days: number = 30 ): Promise<{ date: string; balance: number; pnl: number }[]> { - const account = accounts.get(accountId); + const account = await this.getAccountById(accountId); if (!account) { throw new Error(`Account not found: ${accountId}`); } - // Generate mock performance data + // Generate simulated performance data based on current state + // In production, this would query historical transactions const performance: { date: string; balance: number; pnl: number }[] = []; let balance = account.initialInvestment; @@ -326,16 +416,28 @@ class AccountService { const date = new Date(); date.setDate(date.getDate() - i); - const dailyChange = balance * ((Math.random() - 0.3) * 0.02); - balance += dailyChange; + // Simulate gradual growth towards current balance + const progress = (days - i) / days; + const targetBalance = account.balance; + balance = account.initialInvestment + (targetBalance - account.initialInvestment) * progress; + + // Add some variance + const variance = balance * ((Math.random() - 0.5) * 0.01); + balance += variance; performance.push({ date: date.toISOString().split('T')[0], - balance, - pnl: balance - account.initialInvestment, + balance: Math.round(balance * 100) / 100, + pnl: Math.round((balance - account.initialInvestment) * 100) / 100, }); } + // Ensure last entry matches current balance + if (performance.length > 0) { + performance[performance.length - 1].balance = account.balance; + performance[performance.length - 1].pnl = account.balance - account.initialInvestment; + } + return performance; } } diff --git a/src/modules/investment/services/transaction.service.ts b/src/modules/investment/services/transaction.service.ts index 271b8cc..b140a52 100644 --- a/src/modules/investment/services/transaction.service.ts +++ b/src/modules/investment/services/transaction.service.ts @@ -1,10 +1,18 @@ /** * Investment Transaction Service * Manages deposits, withdrawals, and distributions + * + * Now uses PostgreSQL repository for transactions */ import { v4 as uuidv4 } from 'uuid'; import { accountService } from './account.service'; +import { + transactionRepository, + Transaction as RepoTransaction, + TransactionType as RepoTransactionType, + TransactionStatus as RepoTransactionStatus, +} from '../repositories/transaction.repository'; // ============================================================================ // Types @@ -17,18 +25,53 @@ export type WithdrawalStatus = 'pending' | 'processing' | 'completed' | 'rejecte export interface Transaction { id: string; accountId: string; + transactionNumber: string; userId: string; type: TransactionType; status: TransactionStatus; amount: number; - balanceBefore: number; - balanceAfter: 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; @@ -87,10 +130,11 @@ export interface CreateWithdrawalInput { } // ============================================================================ -// In-Memory Storage +// In-Memory Storage (for entities not yet migrated to PostgreSQL) // ============================================================================ -const transactions: Map = new Map(); +// 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(); @@ -115,36 +159,35 @@ class TransactionService { offset?: number; } = {} ): Promise<{ transactions: Transaction[]; total: number }> { - let accountTransactions = Array.from(transactions.values()) - .filter((t) => t.accountId === accountId) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + // Get account to retrieve userId + const account = await accountService.getAccountById(accountId); + const userId = account?.userId || ''; - if (options.type) { - accountTransactions = accountTransactions.filter((t) => t.type === options.type); - } + 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, + }); - if (options.status) { - accountTransactions = accountTransactions.filter((t) => t.status === options.status); - } - - const total = accountTransactions.length; - - if (options.offset) { - accountTransactions = accountTransactions.slice(options.offset); - } - - if (options.limit) { - accountTransactions = accountTransactions.slice(0, options.limit); - } - - return { transactions: accountTransactions, total }; + return { + transactions: result.transactions.map((t) => mapRepoToService(t, userId)), + total: result.total, + }; } /** * Get transaction by ID */ async getTransactionById(transactionId: string): Promise { - return transactions.get(transactionId) || 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); } /** @@ -162,26 +205,25 @@ class TransactionService { const balanceBefore = account.balance; - // Process deposit - await accountService.deposit(input.accountId, input.amount); - - const transaction: Transaction = { - id: uuidv4(), + // Create transaction record first + const repo = await transactionRepository.create({ accountId: input.accountId, - userId: account.userId, - type: 'deposit', - status: 'completed', + transactionType: 'deposit', amount: input.amount, + paymentReference: input.stripePaymentId, balanceBefore, - balanceAfter: balanceBefore + input.amount, - stripePaymentId: input.stripePaymentId || null, - description: `Deposit of $${input.amount.toFixed(2)}`, - processedAt: new Date(), - createdAt: new Date(), - }; + notes: `Deposit of $${input.amount.toFixed(2)}`, + }); - transactions.set(transaction.id, transaction); - return transaction; + // 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); } /** @@ -197,50 +239,47 @@ class TransactionService { throw new Error(`Account not found: ${accountId}`); } - const transaction: Transaction = { - id: uuidv4(), + const repo = await transactionRepository.create({ accountId, - userId: account.userId, - type: 'deposit', - status: 'pending', + transactionType: 'deposit', amount, + paymentReference: stripePaymentId, balanceBefore: account.balance, - balanceAfter: account.balance, - stripePaymentId, - description: `Pending deposit of $${amount.toFixed(2)}`, - processedAt: null, - createdAt: new Date(), - }; + notes: `Pending deposit of $${amount.toFixed(2)}`, + }); - transactions.set(transaction.id, transaction); - return transaction; + return mapRepoToService(repo, account.userId); } /** * Complete a pending deposit */ async completeDeposit(transactionId: string): Promise { - const transaction = transactions.get(transactionId); - if (!transaction) { + const repo = await transactionRepository.findById(transactionId); + if (!repo) { throw new Error(`Transaction not found: ${transactionId}`); } - if (transaction.status !== 'pending') { + if (repo.status !== 'pending') { throw new Error('Transaction is not pending'); } - // Process deposit - await accountService.deposit(transaction.accountId, transaction.amount); + // Process deposit in account + const updatedAccount = await accountService.deposit(repo.accountId, repo.amount); - // Get updated account - const account = await accountService.getAccountById(transaction.accountId); + // Update transaction status + const completed = await transactionRepository.updateStatus(transactionId, 'completed', { + balanceAfter: updatedAccount.balance, + }); - transaction.status = 'completed'; - transaction.balanceAfter = account!.balance; - transaction.processedAt = new Date(); - transaction.description = `Deposit of $${transaction.amount.toFixed(2)}`; + // Update notes + if (completed) { + await transactionRepository.update(transactionId, { + notes: `Deposit of $${repo.amount.toFixed(2)}`, + }); + } - return transaction; + return mapRepoToService(completed || repo, updatedAccount.userId); } // ========================================================================== @@ -338,23 +377,15 @@ class TransactionService { withdrawalRequests.set(withdrawal.id, withdrawal); - // Create pending transaction - const transaction: Transaction = { - id: uuidv4(), + // Create pending transaction in database + await transactionRepository.create({ accountId: input.accountId, - userId, - type: 'withdrawal', - status: 'pending', - amount: -input.amount, + transactionType: 'withdrawal', + amount: input.amount, balanceBefore: account.balance, - balanceAfter: account.balance, - stripePaymentId: null, - description: `Pending withdrawal of $${input.amount.toFixed(2)}`, - processedAt: null, - createdAt: new Date(), - }; - - transactions.set(transaction.id, transaction); + requiresApproval: true, + notes: `Pending withdrawal of $${input.amount.toFixed(2)}`, + }); return withdrawal; } @@ -391,34 +422,27 @@ class TransactionService { throw new Error('Withdrawal is not being processed'); } - const account = await accountService.getAccountById(withdrawal.accountId); - if (!account) { - throw new Error('Account not found'); - } - - // Deduct from account - account.balance -= withdrawal.amount; - account.totalWithdrawn += withdrawal.amount; - account.updatedAt = new Date(); + // Deduct from account using accountService.withdraw + const updatedAccount = await accountService.withdraw(withdrawal.accountId, withdrawal.amount); // Update withdrawal withdrawal.status = 'completed'; withdrawal.completedAt = new Date(); - // Update transaction - const pendingTx = Array.from(transactions.values()).find( - (t) => - t.accountId === withdrawal.accountId && - t.type === 'withdrawal' && - t.status === 'pending' && - t.amount === -withdrawal.amount + // Find and update the pending transaction in database + const { transactions: pendingTxs } = await transactionRepository.findByAccountId( + withdrawal.accountId, + { transactionType: 'withdrawal', status: 'pending' } ); + const pendingTx = pendingTxs.find((t) => t.amount === withdrawal.amount); if (pendingTx) { - pendingTx.status = 'completed'; - pendingTx.balanceAfter = account.balance; - pendingTx.processedAt = new Date(); - pendingTx.description = `Withdrawal of $${withdrawal.amount.toFixed(2)}`; + await transactionRepository.updateStatus(pendingTx.id, 'completed', { + balanceAfter: updatedAccount.balance, + }); + await transactionRepository.update(pendingTx.id, { + notes: `Withdrawal of $${withdrawal.amount.toFixed(2)}`, + }); } return withdrawal; @@ -444,18 +468,20 @@ class TransactionService { withdrawal.rejectionReason = reason; withdrawal.processedAt = new Date(); - // Cancel transaction - const pendingTx = Array.from(transactions.values()).find( - (t) => - t.accountId === withdrawal.accountId && - t.type === 'withdrawal' && - t.status === 'pending' && - t.amount === -withdrawal.amount + // Find and cancel the pending transaction in database + const { transactions: pendingTxs } = await transactionRepository.findByAccountId( + withdrawal.accountId, + { transactionType: 'withdrawal', status: 'pending' } ); + const pendingTx = pendingTxs.find((t) => t.amount === withdrawal.amount); if (pendingTx) { - pendingTx.status = 'cancelled'; - pendingTx.description = `Withdrawal rejected: ${reason}`; + await transactionRepository.updateStatus(pendingTx.id, 'cancelled', { + failureReason: reason, + }); + await transactionRepository.update(pendingTx.id, { + notes: `Withdrawal rejected: ${reason}`, + }); } return withdrawal; @@ -523,6 +549,10 @@ class TransactionService { throw new Error('Distribution already completed'); } + // Get account balance before earnings + const accountBefore = await accountService.getAccountById(distribution.accountId); + const balanceBefore = accountBefore?.balance || 0; + // Record earnings in account await accountService.recordEarnings( distribution.accountId, @@ -530,43 +560,22 @@ class TransactionService { distribution.performanceFee ); - // Create transactions + // Get updated account const account = await accountService.getAccountById(distribution.accountId); + // Create distribution transaction in database (net earnings) if (distribution.netEarnings !== 0) { - const earningTx: Transaction = { - id: uuidv4(), + const earningTx = await transactionRepository.create({ accountId: distribution.accountId, - userId: distribution.userId, - type: 'earning', - status: 'completed', + transactionType: 'distribution', amount: distribution.netEarnings, - balanceBefore: account!.balance - distribution.netEarnings, - balanceAfter: account!.balance, - stripePaymentId: null, - description: `Earnings for ${distribution.periodStart.toLocaleDateString()} - ${distribution.periodEnd.toLocaleDateString()}`, - processedAt: new Date(), - createdAt: new Date(), - }; - transactions.set(earningTx.id, earningTx); - } - - if (distribution.performanceFee > 0) { - const feeTx: Transaction = { - id: uuidv4(), - accountId: distribution.accountId, - userId: distribution.userId, - type: 'fee', - status: 'completed', - amount: -distribution.performanceFee, - balanceBefore: account!.balance, - balanceAfter: account!.balance, - stripePaymentId: null, - description: `Performance fee (${((distribution.performanceFee / distribution.grossEarnings) * 100).toFixed(0)}%)`, - processedAt: new Date(), - createdAt: new Date(), - }; - transactions.set(feeTx.id, feeTx); + balanceBefore, + balanceAfter: account?.balance, + distributionId, + notes: `Earnings for ${distribution.periodStart.toLocaleDateString()} - ${distribution.periodEnd.toLocaleDateString()}`, + }); + // Mark as completed immediately + await transactionRepository.updateStatus(earningTx.id, 'completed'); } distribution.status = 'distributed';