[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:
parent
35a94f0529
commit
3df1ed1f94
464
src/modules/investment/repositories/account.repository.ts
Normal file
464
src/modules/investment/repositories/account.repository.ts
Normal 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();
|
||||
6
src/modules/investment/repositories/index.ts
Normal file
6
src/modules/investment/repositories/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Investment Repositories - Index
|
||||
*/
|
||||
|
||||
export * from './account.repository';
|
||||
export * from './transaction.repository';
|
||||
504
src/modules/investment/repositories/transaction.repository.ts
Normal file
504
src/modules/investment/repositories/transaction.repository.ts
Normal 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();
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user