[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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 07:44:07 -06:00
parent 35a94f0529
commit 3df1ed1f94
5 changed files with 1334 additions and 249 deletions

View File

@ -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<string> {
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<InvestmentAccount> {
const accountNumber = await generateAccountNumber();
const result = await db.query<AccountRow>(
`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<InvestmentAccount | null> {
const result = await db.query<AccountRow>(
'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<InvestmentAccount | null> {
const result = await db.query<AccountRow>(
'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<InvestmentAccount[]> {
const result = await db.query<AccountRow>(
`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<InvestmentAccount | null> {
const result = await db.query<AccountRow>(
`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<InvestmentAccount[]> {
const result = await db.query<AccountRow>(
`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<InvestmentAccount | null> {
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<AccountRow>(
`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<InvestmentAccount | null> {
return await db.transaction(async (client) => {
// Lock the row for update
const lockResult = await client.query<AccountRow>(
'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<AccountRow>(updateQuery, values);
return mapRowToAccount(result.rows[0]);
});
}
/**
* Close account
*/
async close(id: string): Promise<InvestmentAccount | null> {
return this.update(id, {
status: 'closed',
closedAt: new Date(),
});
}
/**
* Suspend account
*/
async suspend(id: string): Promise<InvestmentAccount | null> {
return this.update(id, {
status: 'suspended',
});
}
/**
* Reactivate account
*/
async reactivate(id: string): Promise<InvestmentAccount | null> {
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();

View File

@ -0,0 +1,6 @@
/**
* Investment Repositories - Index
*/
export * from './account.repository';
export * from './transaction.repository';

View File

@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown>;
distributionId?: string;
balanceBefore?: number;
balanceAfter?: number;
requiresApproval?: boolean;
notes?: string;
}
export interface UpdateTransactionInput {
status?: TransactionStatus;
paymentReference?: string;
paymentMetadata?: Record<string, unknown>;
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<string> {
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<Transaction> {
const transactionNumber = await generateTransactionNumber();
const result = await db.query<TransactionRow>(
`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<Transaction | null> {
const result = await db.query<TransactionRow>(
'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<Transaction | null> {
const result = await db.query<TransactionRow>(
'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<TransactionFilters, 'accountId'>
): 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<TransactionRow>(query, values);
return {
transactions: result.rows.map(mapRowToTransaction),
total,
};
}
/**
* Update transaction
*/
async update(id: string, input: UpdateTransactionInput): Promise<Transaction | null> {
const updates: string[] = [];
const values: (string | number | boolean | Date | null | Record<string, unknown>)[] = [];
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<TransactionRow>(
`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<Transaction | null> {
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<Transaction | null> {
const result = await db.query<TransactionRow>(
`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<number> {
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<Transaction | null> {
return this.update(id, {
approvedBy,
approvedAt: new Date(),
});
}
}
// Export singleton instance
export const transactionRepository = new TransactionRepository();

View File

@ -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<string, InvestmentAccount> = 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<InvestmentAccount[]> {
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<InvestmentAccount | null> {
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<InvestmentAccount | null> {
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<InvestmentAccount | null> {
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<InvestmentAccount> {
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<InvestmentAccount> {
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<InvestmentAccount> {
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<InvestmentAccount> {
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<InvestmentAccount> {
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<InvestmentAccount> {
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<InvestmentAccount> {
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<AccountSummary> {
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<InvestmentAccount[]> {
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;
}
}

View File

@ -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<string, Transaction> = 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<string, WithdrawalRequest> = new Map();
const distributions: Map<string, Distribution> = 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<Transaction | null> {
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<Transaction> {
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';