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