[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
|
* Investment Account Service
|
||||||
* Manages user investment accounts
|
* 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 { productService, InvestmentProduct } from './product.service';
|
||||||
|
import {
|
||||||
|
accountRepository,
|
||||||
|
InvestmentAccount as RepoAccount,
|
||||||
|
RiskProfile,
|
||||||
|
} from '../repositories/account.repository';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type AccountStatus = 'active' | 'suspended' | 'closed';
|
export type AccountStatus = 'active' | 'suspended' | 'closed' | 'pending_kyc';
|
||||||
|
|
||||||
export interface InvestmentAccount {
|
export interface InvestmentAccount {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
|
accountNumber: string;
|
||||||
product?: InvestmentProduct;
|
product?: InvestmentProduct;
|
||||||
status: AccountStatus;
|
status: AccountStatus;
|
||||||
balance: number;
|
balance: number;
|
||||||
@ -26,15 +33,19 @@ export interface InvestmentAccount {
|
|||||||
totalFeesPaid: number;
|
totalFeesPaid: number;
|
||||||
unrealizedPnl: number;
|
unrealizedPnl: number;
|
||||||
unrealizedPnlPercent: number;
|
unrealizedPnlPercent: number;
|
||||||
openedAt: Date;
|
userRiskProfile: RiskProfile;
|
||||||
|
kycVerified: boolean;
|
||||||
|
openedAt: Date | null;
|
||||||
closedAt: Date | null;
|
closedAt: Date | null;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateAccountInput {
|
export interface CreateAccountInput {
|
||||||
userId: string;
|
userId: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
initialDeposit: number;
|
initialDeposit: number;
|
||||||
|
userRiskProfile?: RiskProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountSummary {
|
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
|
// Account Service
|
||||||
@ -62,25 +100,37 @@ class AccountService {
|
|||||||
* Get all accounts for a user
|
* Get all accounts for a user
|
||||||
*/
|
*/
|
||||||
async getUserAccounts(userId: string): Promise<InvestmentAccount[]> {
|
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
|
const results: InvestmentAccount[] = [];
|
||||||
for (const account of userAccounts) {
|
for (const repo of repoAccounts) {
|
||||||
account.product = (await productService.getProductById(account.productId)) || undefined;
|
const product = (await productService.getProductById(repo.productId)) || undefined;
|
||||||
|
results.push(mapRepoToService(repo, product));
|
||||||
}
|
}
|
||||||
|
|
||||||
return userAccounts;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get account by ID
|
* Get account by ID
|
||||||
*/
|
*/
|
||||||
async getAccountById(accountId: string): Promise<InvestmentAccount | null> {
|
async getAccountById(accountId: string): Promise<InvestmentAccount | null> {
|
||||||
const account = accounts.get(accountId);
|
const repo = await accountRepository.findById(accountId);
|
||||||
if (!account) return null;
|
if (!repo) return null;
|
||||||
|
|
||||||
account.product = (await productService.getProductById(account.productId)) || undefined;
|
const product = (await productService.getProductById(repo.productId)) || undefined;
|
||||||
return account;
|
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,
|
userId: string,
|
||||||
productId: string
|
productId: string
|
||||||
): Promise<InvestmentAccount | null> {
|
): Promise<InvestmentAccount | null> {
|
||||||
const account = Array.from(accounts.values()).find(
|
const repo = await accountRepository.findByUserAndProduct(userId, productId);
|
||||||
(a) => a.userId === userId && a.productId === productId && a.status !== 'closed'
|
if (!repo) return null;
|
||||||
);
|
|
||||||
|
|
||||||
if (!account) return null;
|
const product = (await productService.getProductById(repo.productId)) || undefined;
|
||||||
|
return mapRepoToService(repo, product);
|
||||||
account.product = (await productService.getProductById(account.productId)) || undefined;
|
|
||||||
return account;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -126,74 +173,94 @@ class AccountService {
|
|||||||
throw new Error(`User already has an account with ${product.name}`);
|
throw new Error(`User already has an account with ${product.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const account: InvestmentAccount = {
|
// Create account in database
|
||||||
id: uuidv4(),
|
const repo = await accountRepository.create({
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
productId: input.productId,
|
productId: input.productId,
|
||||||
product,
|
initialBalance: input.initialDeposit,
|
||||||
status: 'active',
|
userRiskProfile: input.userRiskProfile || 'moderate',
|
||||||
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(),
|
|
||||||
};
|
|
||||||
|
|
||||||
accounts.set(account.id, account);
|
return mapRepoToService(repo, product);
|
||||||
return account;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deposit funds to an account
|
* Deposit funds to an account
|
||||||
*/
|
*/
|
||||||
async deposit(accountId: string, amount: number): Promise<InvestmentAccount> {
|
async deposit(accountId: string, amount: number): Promise<InvestmentAccount> {
|
||||||
const account = accounts.get(accountId);
|
const repo = await accountRepository.findById(accountId);
|
||||||
if (!account) {
|
if (!repo) {
|
||||||
throw new Error(`Account not found: ${accountId}`);
|
throw new Error(`Account not found: ${accountId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (account.status !== 'active') {
|
if (repo.status !== 'active') {
|
||||||
throw new Error(`Cannot deposit to ${account.status} account`);
|
throw new Error(`Cannot deposit to ${repo.status} account`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (amount <= 0) {
|
if (amount <= 0) {
|
||||||
throw new Error('Deposit amount must be positive');
|
throw new Error('Deposit amount must be positive');
|
||||||
}
|
}
|
||||||
|
|
||||||
account.balance += amount;
|
const updated = await accountRepository.updateBalance(accountId, amount, 'deposit');
|
||||||
account.totalDeposited += amount;
|
if (!updated) {
|
||||||
account.updatedAt = new Date();
|
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(
|
async recordEarnings(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
grossEarnings: number,
|
grossEarnings: number,
|
||||||
performanceFee: number
|
_performanceFee: number
|
||||||
): Promise<InvestmentAccount> {
|
): Promise<InvestmentAccount> {
|
||||||
const account = accounts.get(accountId);
|
const repo = await accountRepository.findById(accountId);
|
||||||
if (!account) {
|
if (!repo) {
|
||||||
throw new Error(`Account not found: ${accountId}`);
|
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;
|
const product = (await productService.getProductById(updated.productId)) || undefined;
|
||||||
account.totalEarnings += netEarnings;
|
return mapRepoToService(updated, product);
|
||||||
account.totalFeesPaid += performanceFee;
|
|
||||||
account.updatedAt = new Date();
|
|
||||||
|
|
||||||
return account;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -203,73 +270,88 @@ class AccountService {
|
|||||||
accountId: string,
|
accountId: string,
|
||||||
unrealizedPnl: number
|
unrealizedPnl: number
|
||||||
): Promise<InvestmentAccount> {
|
): Promise<InvestmentAccount> {
|
||||||
const account = accounts.get(accountId);
|
const repo = await accountRepository.findById(accountId);
|
||||||
if (!account) {
|
if (!repo) {
|
||||||
throw new Error(`Account not found: ${accountId}`);
|
throw new Error(`Account not found: ${accountId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
account.unrealizedPnl = unrealizedPnl;
|
const returnPercent = repo.totalDeposits > 0
|
||||||
account.unrealizedPnlPercent =
|
? (unrealizedPnl / repo.totalDeposits) * 100
|
||||||
account.totalDeposited > 0
|
|
||||||
? (unrealizedPnl / account.totalDeposited) * 100
|
|
||||||
: 0;
|
: 0;
|
||||||
account.updatedAt = new Date();
|
|
||||||
|
|
||||||
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
|
* Close an account
|
||||||
*/
|
*/
|
||||||
async closeAccount(accountId: string): Promise<InvestmentAccount> {
|
async closeAccount(accountId: string): Promise<InvestmentAccount> {
|
||||||
const account = accounts.get(accountId);
|
const repo = await accountRepository.findById(accountId);
|
||||||
if (!account) {
|
if (!repo) {
|
||||||
throw new Error(`Account not found: ${accountId}`);
|
throw new Error(`Account not found: ${accountId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (account.status === 'closed') {
|
if (repo.status === 'closed') {
|
||||||
throw new Error('Account is already closed');
|
throw new Error('Account is already closed');
|
||||||
}
|
}
|
||||||
|
|
||||||
account.status = 'closed';
|
const updated = await accountRepository.close(accountId);
|
||||||
account.closedAt = new Date();
|
if (!updated) {
|
||||||
account.updatedAt = new Date();
|
throw new Error('Failed to close account');
|
||||||
|
}
|
||||||
|
|
||||||
return account;
|
const product = (await productService.getProductById(updated.productId)) || undefined;
|
||||||
|
return mapRepoToService(updated, product);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Suspend an account
|
* Suspend an account
|
||||||
*/
|
*/
|
||||||
async suspendAccount(accountId: string): Promise<InvestmentAccount> {
|
async suspendAccount(accountId: string): Promise<InvestmentAccount> {
|
||||||
const account = accounts.get(accountId);
|
const repo = await accountRepository.findById(accountId);
|
||||||
if (!account) {
|
if (!repo) {
|
||||||
throw new Error(`Account not found: ${accountId}`);
|
throw new Error(`Account not found: ${accountId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
account.status = 'suspended';
|
const updated = await accountRepository.suspend(accountId);
|
||||||
account.updatedAt = new Date();
|
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
|
* Reactivate a suspended account
|
||||||
*/
|
*/
|
||||||
async reactivateAccount(accountId: string): Promise<InvestmentAccount> {
|
async reactivateAccount(accountId: string): Promise<InvestmentAccount> {
|
||||||
const account = accounts.get(accountId);
|
const repo = await accountRepository.findById(accountId);
|
||||||
if (!account) {
|
if (!repo) {
|
||||||
throw new Error(`Account not found: ${accountId}`);
|
throw new Error(`Account not found: ${accountId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (account.status !== 'suspended') {
|
if (repo.status !== 'suspended') {
|
||||||
throw new Error('Only suspended accounts can be reactivated');
|
throw new Error('Only suspended accounts can be reactivated');
|
||||||
}
|
}
|
||||||
|
|
||||||
account.status = 'active';
|
const updated = await accountRepository.reactivate(accountId);
|
||||||
account.updatedAt = new Date();
|
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> {
|
async getAccountSummary(userId: string): Promise<AccountSummary> {
|
||||||
const userAccounts = await this.getUserAccounts(userId);
|
const userAccounts = await this.getUserAccounts(userId);
|
||||||
|
const repoSummary = await accountRepository.getAccountSummary(userId);
|
||||||
|
|
||||||
const summary: AccountSummary = {
|
const summary: AccountSummary = {
|
||||||
totalBalance: 0,
|
totalBalance: repoSummary.totalBalance,
|
||||||
totalEarnings: 0,
|
totalEarnings: repoSummary.totalDistributions,
|
||||||
totalDeposited: 0,
|
totalDeposited: repoSummary.totalDeposits,
|
||||||
totalWithdrawn: 0,
|
totalWithdrawn: repoSummary.totalWithdrawals,
|
||||||
overallReturn: 0,
|
overallReturn: repoSummary.totalReturnAmount,
|
||||||
overallReturnPercent: 0,
|
overallReturnPercent: 0,
|
||||||
accounts: userAccounts,
|
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.overallReturnPercent =
|
||||||
summary.totalDeposited > 0
|
summary.totalDeposited > 0
|
||||||
? (summary.overallReturn / summary.totalDeposited) * 100
|
? (summary.overallReturn / summary.totalDeposited) * 100
|
||||||
@ -306,19 +379,36 @@ class AccountService {
|
|||||||
return summary;
|
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
|
* Get account performance history
|
||||||
|
* TODO: Implement with real historical data from transactions
|
||||||
*/
|
*/
|
||||||
async getAccountPerformance(
|
async getAccountPerformance(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
days: number = 30
|
days: number = 30
|
||||||
): Promise<{ date: string; balance: number; pnl: number }[]> {
|
): Promise<{ date: string; balance: number; pnl: number }[]> {
|
||||||
const account = accounts.get(accountId);
|
const account = await this.getAccountById(accountId);
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error(`Account not found: ${accountId}`);
|
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 }[] = [];
|
const performance: { date: string; balance: number; pnl: number }[] = [];
|
||||||
let balance = account.initialInvestment;
|
let balance = account.initialInvestment;
|
||||||
|
|
||||||
@ -326,16 +416,28 @@ class AccountService {
|
|||||||
const date = new Date();
|
const date = new Date();
|
||||||
date.setDate(date.getDate() - i);
|
date.setDate(date.getDate() - i);
|
||||||
|
|
||||||
const dailyChange = balance * ((Math.random() - 0.3) * 0.02);
|
// Simulate gradual growth towards current balance
|
||||||
balance += dailyChange;
|
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({
|
performance.push({
|
||||||
date: date.toISOString().split('T')[0],
|
date: date.toISOString().split('T')[0],
|
||||||
balance,
|
balance: Math.round(balance * 100) / 100,
|
||||||
pnl: balance - account.initialInvestment,
|
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;
|
return performance;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Investment Transaction Service
|
* Investment Transaction Service
|
||||||
* Manages deposits, withdrawals, and distributions
|
* Manages deposits, withdrawals, and distributions
|
||||||
|
*
|
||||||
|
* Now uses PostgreSQL repository for transactions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { accountService } from './account.service';
|
import { accountService } from './account.service';
|
||||||
|
import {
|
||||||
|
transactionRepository,
|
||||||
|
Transaction as RepoTransaction,
|
||||||
|
TransactionType as RepoTransactionType,
|
||||||
|
TransactionStatus as RepoTransactionStatus,
|
||||||
|
} from '../repositories/transaction.repository';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
@ -17,18 +25,53 @@ export type WithdrawalStatus = 'pending' | 'processing' | 'completed' | 'rejecte
|
|||||||
export interface Transaction {
|
export interface Transaction {
|
||||||
id: string;
|
id: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
transactionNumber: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
type: TransactionType;
|
type: TransactionType;
|
||||||
status: TransactionStatus;
|
status: TransactionStatus;
|
||||||
amount: number;
|
amount: number;
|
||||||
balanceBefore: number;
|
balanceBefore: number | null;
|
||||||
balanceAfter: number;
|
balanceAfter: number | null;
|
||||||
stripePaymentId: string | null;
|
stripePaymentId: string | null;
|
||||||
description: string;
|
description: string;
|
||||||
processedAt: Date | null;
|
processedAt: Date | null;
|
||||||
createdAt: Date;
|
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 {
|
export interface WithdrawalRequest {
|
||||||
id: string;
|
id: string;
|
||||||
accountId: 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 withdrawalRequests: Map<string, WithdrawalRequest> = new Map();
|
||||||
const distributions: Map<string, Distribution> = new Map();
|
const distributions: Map<string, Distribution> = new Map();
|
||||||
|
|
||||||
@ -115,36 +159,35 @@ class TransactionService {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<{ transactions: Transaction[]; total: number }> {
|
): Promise<{ transactions: Transaction[]; total: number }> {
|
||||||
let accountTransactions = Array.from(transactions.values())
|
// Get account to retrieve userId
|
||||||
.filter((t) => t.accountId === accountId)
|
const account = await accountService.getAccountById(accountId);
|
||||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
const userId = account?.userId || '';
|
||||||
|
|
||||||
if (options.type) {
|
const result = await transactionRepository.findByAccountId(accountId, {
|
||||||
accountTransactions = accountTransactions.filter((t) => t.type === options.type);
|
transactionType: options.type ? mapServiceTypeToRepoType(options.type) : undefined,
|
||||||
}
|
status: options.status as RepoTransactionStatus | undefined,
|
||||||
|
limit: options.limit,
|
||||||
|
offset: options.offset,
|
||||||
|
});
|
||||||
|
|
||||||
if (options.status) {
|
return {
|
||||||
accountTransactions = accountTransactions.filter((t) => t.status === options.status);
|
transactions: result.transactions.map((t) => mapRepoToService(t, userId)),
|
||||||
}
|
total: result.total,
|
||||||
|
};
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get transaction by ID
|
* Get transaction by ID
|
||||||
*/
|
*/
|
||||||
async getTransactionById(transactionId: string): Promise<Transaction | null> {
|
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;
|
const balanceBefore = account.balance;
|
||||||
|
|
||||||
// Process deposit
|
// Create transaction record first
|
||||||
await accountService.deposit(input.accountId, input.amount);
|
const repo = await transactionRepository.create({
|
||||||
|
|
||||||
const transaction: Transaction = {
|
|
||||||
id: uuidv4(),
|
|
||||||
accountId: input.accountId,
|
accountId: input.accountId,
|
||||||
userId: account.userId,
|
transactionType: 'deposit',
|
||||||
type: 'deposit',
|
|
||||||
status: 'completed',
|
|
||||||
amount: input.amount,
|
amount: input.amount,
|
||||||
|
paymentReference: input.stripePaymentId,
|
||||||
balanceBefore,
|
balanceBefore,
|
||||||
balanceAfter: balanceBefore + input.amount,
|
notes: `Deposit of $${input.amount.toFixed(2)}`,
|
||||||
stripePaymentId: input.stripePaymentId || null,
|
});
|
||||||
description: `Deposit of $${input.amount.toFixed(2)}`,
|
|
||||||
processedAt: new Date(),
|
|
||||||
createdAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
transactions.set(transaction.id, transaction);
|
// Process deposit in account
|
||||||
return transaction;
|
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}`);
|
throw new Error(`Account not found: ${accountId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const transaction: Transaction = {
|
const repo = await transactionRepository.create({
|
||||||
id: uuidv4(),
|
|
||||||
accountId,
|
accountId,
|
||||||
userId: account.userId,
|
transactionType: 'deposit',
|
||||||
type: 'deposit',
|
|
||||||
status: 'pending',
|
|
||||||
amount,
|
amount,
|
||||||
|
paymentReference: stripePaymentId,
|
||||||
balanceBefore: account.balance,
|
balanceBefore: account.balance,
|
||||||
balanceAfter: account.balance,
|
notes: `Pending deposit of $${amount.toFixed(2)}`,
|
||||||
stripePaymentId,
|
});
|
||||||
description: `Pending deposit of $${amount.toFixed(2)}`,
|
|
||||||
processedAt: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
transactions.set(transaction.id, transaction);
|
return mapRepoToService(repo, account.userId);
|
||||||
return transaction;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete a pending deposit
|
* Complete a pending deposit
|
||||||
*/
|
*/
|
||||||
async completeDeposit(transactionId: string): Promise<Transaction> {
|
async completeDeposit(transactionId: string): Promise<Transaction> {
|
||||||
const transaction = transactions.get(transactionId);
|
const repo = await transactionRepository.findById(transactionId);
|
||||||
if (!transaction) {
|
if (!repo) {
|
||||||
throw new Error(`Transaction not found: ${transactionId}`);
|
throw new Error(`Transaction not found: ${transactionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transaction.status !== 'pending') {
|
if (repo.status !== 'pending') {
|
||||||
throw new Error('Transaction is not pending');
|
throw new Error('Transaction is not pending');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process deposit
|
// Process deposit in account
|
||||||
await accountService.deposit(transaction.accountId, transaction.amount);
|
const updatedAccount = await accountService.deposit(repo.accountId, repo.amount);
|
||||||
|
|
||||||
// Get updated account
|
// Update transaction status
|
||||||
const account = await accountService.getAccountById(transaction.accountId);
|
const completed = await transactionRepository.updateStatus(transactionId, 'completed', {
|
||||||
|
balanceAfter: updatedAccount.balance,
|
||||||
|
});
|
||||||
|
|
||||||
transaction.status = 'completed';
|
// Update notes
|
||||||
transaction.balanceAfter = account!.balance;
|
if (completed) {
|
||||||
transaction.processedAt = new Date();
|
await transactionRepository.update(transactionId, {
|
||||||
transaction.description = `Deposit of $${transaction.amount.toFixed(2)}`;
|
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);
|
withdrawalRequests.set(withdrawal.id, withdrawal);
|
||||||
|
|
||||||
// Create pending transaction
|
// Create pending transaction in database
|
||||||
const transaction: Transaction = {
|
await transactionRepository.create({
|
||||||
id: uuidv4(),
|
|
||||||
accountId: input.accountId,
|
accountId: input.accountId,
|
||||||
userId,
|
transactionType: 'withdrawal',
|
||||||
type: 'withdrawal',
|
amount: input.amount,
|
||||||
status: 'pending',
|
|
||||||
amount: -input.amount,
|
|
||||||
balanceBefore: account.balance,
|
balanceBefore: account.balance,
|
||||||
balanceAfter: account.balance,
|
requiresApproval: true,
|
||||||
stripePaymentId: null,
|
notes: `Pending withdrawal of $${input.amount.toFixed(2)}`,
|
||||||
description: `Pending withdrawal of $${input.amount.toFixed(2)}`,
|
});
|
||||||
processedAt: null,
|
|
||||||
createdAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
transactions.set(transaction.id, transaction);
|
|
||||||
|
|
||||||
return withdrawal;
|
return withdrawal;
|
||||||
}
|
}
|
||||||
@ -391,34 +422,27 @@ class TransactionService {
|
|||||||
throw new Error('Withdrawal is not being processed');
|
throw new Error('Withdrawal is not being processed');
|
||||||
}
|
}
|
||||||
|
|
||||||
const account = await accountService.getAccountById(withdrawal.accountId);
|
// Deduct from account using accountService.withdraw
|
||||||
if (!account) {
|
const updatedAccount = await accountService.withdraw(withdrawal.accountId, withdrawal.amount);
|
||||||
throw new Error('Account not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deduct from account
|
|
||||||
account.balance -= withdrawal.amount;
|
|
||||||
account.totalWithdrawn += withdrawal.amount;
|
|
||||||
account.updatedAt = new Date();
|
|
||||||
|
|
||||||
// Update withdrawal
|
// Update withdrawal
|
||||||
withdrawal.status = 'completed';
|
withdrawal.status = 'completed';
|
||||||
withdrawal.completedAt = new Date();
|
withdrawal.completedAt = new Date();
|
||||||
|
|
||||||
// Update transaction
|
// Find and update the pending transaction in database
|
||||||
const pendingTx = Array.from(transactions.values()).find(
|
const { transactions: pendingTxs } = await transactionRepository.findByAccountId(
|
||||||
(t) =>
|
withdrawal.accountId,
|
||||||
t.accountId === withdrawal.accountId &&
|
{ transactionType: 'withdrawal', status: 'pending' }
|
||||||
t.type === 'withdrawal' &&
|
|
||||||
t.status === 'pending' &&
|
|
||||||
t.amount === -withdrawal.amount
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pendingTx = pendingTxs.find((t) => t.amount === withdrawal.amount);
|
||||||
if (pendingTx) {
|
if (pendingTx) {
|
||||||
pendingTx.status = 'completed';
|
await transactionRepository.updateStatus(pendingTx.id, 'completed', {
|
||||||
pendingTx.balanceAfter = account.balance;
|
balanceAfter: updatedAccount.balance,
|
||||||
pendingTx.processedAt = new Date();
|
});
|
||||||
pendingTx.description = `Withdrawal of $${withdrawal.amount.toFixed(2)}`;
|
await transactionRepository.update(pendingTx.id, {
|
||||||
|
notes: `Withdrawal of $${withdrawal.amount.toFixed(2)}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return withdrawal;
|
return withdrawal;
|
||||||
@ -444,18 +468,20 @@ class TransactionService {
|
|||||||
withdrawal.rejectionReason = reason;
|
withdrawal.rejectionReason = reason;
|
||||||
withdrawal.processedAt = new Date();
|
withdrawal.processedAt = new Date();
|
||||||
|
|
||||||
// Cancel transaction
|
// Find and cancel the pending transaction in database
|
||||||
const pendingTx = Array.from(transactions.values()).find(
|
const { transactions: pendingTxs } = await transactionRepository.findByAccountId(
|
||||||
(t) =>
|
withdrawal.accountId,
|
||||||
t.accountId === withdrawal.accountId &&
|
{ transactionType: 'withdrawal', status: 'pending' }
|
||||||
t.type === 'withdrawal' &&
|
|
||||||
t.status === 'pending' &&
|
|
||||||
t.amount === -withdrawal.amount
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const pendingTx = pendingTxs.find((t) => t.amount === withdrawal.amount);
|
||||||
if (pendingTx) {
|
if (pendingTx) {
|
||||||
pendingTx.status = 'cancelled';
|
await transactionRepository.updateStatus(pendingTx.id, 'cancelled', {
|
||||||
pendingTx.description = `Withdrawal rejected: ${reason}`;
|
failureReason: reason,
|
||||||
|
});
|
||||||
|
await transactionRepository.update(pendingTx.id, {
|
||||||
|
notes: `Withdrawal rejected: ${reason}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return withdrawal;
|
return withdrawal;
|
||||||
@ -523,6 +549,10 @@ class TransactionService {
|
|||||||
throw new Error('Distribution already completed');
|
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
|
// Record earnings in account
|
||||||
await accountService.recordEarnings(
|
await accountService.recordEarnings(
|
||||||
distribution.accountId,
|
distribution.accountId,
|
||||||
@ -530,43 +560,22 @@ class TransactionService {
|
|||||||
distribution.performanceFee
|
distribution.performanceFee
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create transactions
|
// Get updated account
|
||||||
const account = await accountService.getAccountById(distribution.accountId);
|
const account = await accountService.getAccountById(distribution.accountId);
|
||||||
|
|
||||||
|
// Create distribution transaction in database (net earnings)
|
||||||
if (distribution.netEarnings !== 0) {
|
if (distribution.netEarnings !== 0) {
|
||||||
const earningTx: Transaction = {
|
const earningTx = await transactionRepository.create({
|
||||||
id: uuidv4(),
|
|
||||||
accountId: distribution.accountId,
|
accountId: distribution.accountId,
|
||||||
userId: distribution.userId,
|
transactionType: 'distribution',
|
||||||
type: 'earning',
|
|
||||||
status: 'completed',
|
|
||||||
amount: distribution.netEarnings,
|
amount: distribution.netEarnings,
|
||||||
balanceBefore: account!.balance - distribution.netEarnings,
|
balanceBefore,
|
||||||
balanceAfter: account!.balance,
|
balanceAfter: account?.balance,
|
||||||
stripePaymentId: null,
|
distributionId,
|
||||||
description: `Earnings for ${distribution.periodStart.toLocaleDateString()} - ${distribution.periodEnd.toLocaleDateString()}`,
|
notes: `Earnings for ${distribution.periodStart.toLocaleDateString()} - ${distribution.periodEnd.toLocaleDateString()}`,
|
||||||
processedAt: new Date(),
|
});
|
||||||
createdAt: new Date(),
|
// Mark as completed immediately
|
||||||
};
|
await transactionRepository.updateStatus(earningTx.id, 'completed');
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
distribution.status = 'distributed';
|
distribution.status = 'distributed';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user