[OQI-004] feat: Complete PostgreSQL migration for investment module

- withdrawal.repository.ts: CRUD for withdrawal_requests table
- distribution.repository.ts: CRUD for profit_distributions table
- product.repository.ts: CRUD for products table with DB/in-memory fallback
- transaction.service.ts: Migrated withdrawal and distribution to repositories
- product.service.ts: Added DB support with in-memory defaults fallback

All investment entities now persist to PostgreSQL instead of in-memory storage.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 07:57:18 -06:00
parent 3df1ed1f94
commit 4322caf69a
6 changed files with 1460 additions and 123 deletions

View File

@ -0,0 +1,354 @@
/**
* Investment Distribution Repository
* Handles database operations for profit distributions
*/
import { db } from '../../../shared/database';
// ============================================================================
// Types
// ============================================================================
export type DistributionStatus = 'pending' | 'approved' | 'distributed' | 'cancelled';
export interface DistributionRow {
id: string;
account_id: string;
period_start: Date;
period_end: Date;
gross_profit: string;
management_fee: string;
net_profit: string;
platform_share_percent: string;
client_share_percent: string;
platform_amount: string;
client_amount: string;
status: DistributionStatus;
distributed_at: Date | null;
approved_by: string | null;
payment_reference: string | null;
created_at: Date;
updated_at: Date;
}
export interface Distribution {
id: string;
accountId: string;
periodStart: Date;
periodEnd: Date;
grossProfit: number;
managementFee: number;
netProfit: number;
platformSharePercent: number;
clientSharePercent: number;
platformAmount: number;
clientAmount: number;
status: DistributionStatus;
distributedAt: Date | null;
approvedBy: string | null;
paymentReference: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface CreateDistributionInput {
accountId: string;
periodStart: Date;
periodEnd: Date;
grossProfit: number;
managementFee?: number;
platformSharePercent: number;
clientSharePercent: number;
}
export interface DistributionFilters {
accountId?: string;
status?: DistributionStatus;
periodStart?: Date;
periodEnd?: Date;
limit?: number;
offset?: number;
}
// ============================================================================
// Helper Functions
// ============================================================================
function mapRowToDistribution(row: DistributionRow): Distribution {
return {
id: row.id,
accountId: row.account_id,
periodStart: row.period_start,
periodEnd: row.period_end,
grossProfit: parseFloat(row.gross_profit),
managementFee: parseFloat(row.management_fee || '0'),
netProfit: parseFloat(row.net_profit),
platformSharePercent: parseFloat(row.platform_share_percent),
clientSharePercent: parseFloat(row.client_share_percent),
platformAmount: parseFloat(row.platform_amount),
clientAmount: parseFloat(row.client_amount),
status: row.status,
distributedAt: row.distributed_at,
approvedBy: row.approved_by,
paymentReference: row.payment_reference,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
// ============================================================================
// Repository Class
// ============================================================================
class DistributionRepository {
/**
* Create a new distribution
*/
async create(input: CreateDistributionInput): Promise<Distribution> {
const managementFee = input.managementFee || 0;
const netProfit = input.grossProfit - managementFee;
const platformAmount = netProfit * (input.platformSharePercent / 100);
const clientAmount = netProfit * (input.clientSharePercent / 100);
const result = await db.query<DistributionRow>(
`INSERT INTO investment.profit_distributions (
account_id, period_start, period_end, gross_profit, management_fee,
net_profit, platform_share_percent, client_share_percent,
platform_amount, client_amount, status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'pending')
RETURNING *`,
[
input.accountId,
input.periodStart,
input.periodEnd,
input.grossProfit,
managementFee,
netProfit,
input.platformSharePercent,
input.clientSharePercent,
platformAmount,
clientAmount,
]
);
return mapRowToDistribution(result.rows[0]);
}
/**
* Find distribution by ID
*/
async findById(id: string): Promise<Distribution | null> {
const result = await db.query<DistributionRow>(
'SELECT * FROM investment.profit_distributions WHERE id = $1',
[id]
);
if (result.rows.length === 0) {
return null;
}
return mapRowToDistribution(result.rows[0]);
}
/**
* Find distributions by account
*/
async findByAccountId(accountId: string): Promise<Distribution[]> {
const result = await db.query<DistributionRow>(
`SELECT * FROM investment.profit_distributions
WHERE account_id = $1
ORDER BY period_end DESC`,
[accountId]
);
return result.rows.map(mapRowToDistribution);
}
/**
* Find all distributions with filters
*/
async findAll(filters: DistributionFilters = {}): Promise<{
distributions: Distribution[];
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.status) {
conditions.push(`status = $${paramIndex++}`);
values.push(filters.status);
}
if (filters.periodStart) {
conditions.push(`period_start >= $${paramIndex++}`);
values.push(filters.periodStart);
}
if (filters.periodEnd) {
conditions.push(`period_end <= $${paramIndex++}`);
values.push(filters.periodEnd);
}
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.profit_distributions ${whereClause}`,
values
);
const total = parseInt(countResult.rows[0].count);
// Get distributions
let query = `
SELECT * FROM investment.profit_distributions
${whereClause}
ORDER BY period_end 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<DistributionRow>(query, values);
return {
distributions: result.rows.map(mapRowToDistribution),
total,
};
}
/**
* Find pending distributions
*/
async findPending(): Promise<Distribution[]> {
const result = await db.query<DistributionRow>(
`SELECT * FROM investment.profit_distributions
WHERE status = 'pending'
ORDER BY period_end ASC`
);
return result.rows.map(mapRowToDistribution);
}
/**
* Approve a distribution
*/
async approve(id: string, approvedBy: string): Promise<Distribution | null> {
const result = await db.query<DistributionRow>(
`UPDATE investment.profit_distributions
SET status = 'approved', approved_by = $2, updated_at = NOW()
WHERE id = $1 AND status = 'pending'
RETURNING *`,
[id, approvedBy]
);
if (result.rows.length === 0) {
return null;
}
return mapRowToDistribution(result.rows[0]);
}
/**
* Mark distribution as distributed
*/
async distribute(
id: string,
paymentReference?: string
): Promise<Distribution | null> {
const result = await db.query<DistributionRow>(
`UPDATE investment.profit_distributions
SET status = 'distributed', distributed_at = NOW(),
payment_reference = $2, updated_at = NOW()
WHERE id = $1 AND status IN ('pending', 'approved')
RETURNING *`,
[id, paymentReference || null]
);
if (result.rows.length === 0) {
return null;
}
return mapRowToDistribution(result.rows[0]);
}
/**
* Cancel a distribution
*/
async cancel(id: string): Promise<Distribution | null> {
const result = await db.query<DistributionRow>(
`UPDATE investment.profit_distributions
SET status = 'cancelled', updated_at = NOW()
WHERE id = $1 AND status IN ('pending', 'approved')
RETURNING *`,
[id]
);
if (result.rows.length === 0) {
return null;
}
return mapRowToDistribution(result.rows[0]);
}
/**
* Get total distributed to an account
*/
async getTotalDistributed(accountId: string): Promise<number> {
const result = await db.query<{ total: string }>(
`SELECT COALESCE(SUM(client_amount), 0) as total
FROM investment.profit_distributions
WHERE account_id = $1 AND status = 'distributed'`,
[accountId]
);
return parseFloat(result.rows[0].total);
}
/**
* Get distribution statistics
*/
async getStats(): Promise<{
pendingCount: number;
pendingAmount: number;
distributedCount: number;
distributedAmount: number;
}> {
const result = await db.query<{
pending_count: string;
pending_amount: string;
distributed_count: string;
distributed_amount: string;
}>(`
SELECT
COUNT(*) FILTER (WHERE status = 'pending') as pending_count,
COALESCE(SUM(client_amount) FILTER (WHERE status = 'pending'), 0) as pending_amount,
COUNT(*) FILTER (WHERE status = 'distributed') as distributed_count,
COALESCE(SUM(client_amount) FILTER (WHERE status = 'distributed'), 0) as distributed_amount
FROM investment.profit_distributions
`);
const row = result.rows[0];
return {
pendingCount: parseInt(row.pending_count),
pendingAmount: parseFloat(row.pending_amount),
distributedCount: parseInt(row.distributed_count),
distributedAmount: parseFloat(row.distributed_amount),
};
}
}
// Export singleton instance
export const distributionRepository = new DistributionRepository();

View File

@ -4,3 +4,15 @@
export * from './account.repository'; export * from './account.repository';
export * from './transaction.repository'; export * from './transaction.repository';
export * from './withdrawal.repository';
export * from './distribution.repository';
// Product repository exports (RiskProfile already exported from account.repository)
export { productRepository } from './product.repository';
export type {
InvestmentProduct as ProductEntity,
ProductType,
ProductRow,
CreateProductInput,
UpdateProductInput,
} from './product.repository';

View File

@ -0,0 +1,437 @@
/**
* Investment Product Repository
* Handles database operations for investment products
*/
import { db } from '../../../shared/database';
// ============================================================================
// Types
// ============================================================================
export type ProductType = 'fixed_return' | 'variable_return' | 'long_term_portfolio';
export type RiskProfile = 'conservative' | 'moderate' | 'aggressive';
export interface ProductRow {
id: string;
name: string;
slug: string;
description: string | null;
short_description: string | null;
product_type: ProductType;
risk_profile: RiskProfile;
target_monthly_return: string | null;
max_drawdown: string | null;
guaranteed_return: boolean;
management_fee_percent: string;
performance_fee_percent: string;
profit_share_platform: string | null;
profit_share_client: string | null;
min_investment: string;
max_investment: string | null;
min_investment_period_days: number;
requires_kyc_level: number;
allowed_risk_profiles: RiskProfile[] | null;
default_bot_id: string | null;
is_active: boolean;
is_visible: boolean;
terms_url: string | null;
risk_disclosure_url: string | null;
created_at: Date;
updated_at: Date;
}
export interface InvestmentProduct {
id: string;
name: string;
slug: string;
description: string | null;
shortDescription: string | null;
productType: ProductType;
riskProfile: RiskProfile;
targetMonthlyReturn: number | null;
maxDrawdown: number | null;
guaranteedReturn: boolean;
managementFeePercent: number;
performanceFeePercent: number;
profitSharePlatform: number | null;
profitShareClient: number | null;
minInvestment: number;
maxInvestment: number | null;
minInvestmentPeriodDays: number;
requiresKycLevel: number;
allowedRiskProfiles: RiskProfile[] | null;
defaultBotId: string | null;
isActive: boolean;
isVisible: boolean;
termsUrl: string | null;
riskDisclosureUrl: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface CreateProductInput {
name: string;
slug: string;
description?: string;
shortDescription?: string;
productType: ProductType;
riskProfile: RiskProfile;
targetMonthlyReturn?: number;
maxDrawdown?: number;
managementFeePercent?: number;
performanceFeePercent?: number;
profitSharePlatform?: number;
profitShareClient?: number;
minInvestment?: number;
maxInvestment?: number;
minInvestmentPeriodDays?: number;
requiresKycLevel?: number;
allowedRiskProfiles?: RiskProfile[];
defaultBotId?: string;
termsUrl?: string;
riskDisclosureUrl?: string;
}
export interface UpdateProductInput {
name?: string;
description?: string;
shortDescription?: string;
targetMonthlyReturn?: number;
maxDrawdown?: number;
managementFeePercent?: number;
performanceFeePercent?: number;
profitSharePlatform?: number;
profitShareClient?: number;
minInvestment?: number;
maxInvestment?: number;
minInvestmentPeriodDays?: number;
requiresKycLevel?: number;
allowedRiskProfiles?: RiskProfile[];
defaultBotId?: string;
isActive?: boolean;
isVisible?: boolean;
termsUrl?: string;
riskDisclosureUrl?: string;
}
// ============================================================================
// Helper Functions
// ============================================================================
function mapRowToProduct(row: ProductRow): InvestmentProduct {
return {
id: row.id,
name: row.name,
slug: row.slug,
description: row.description,
shortDescription: row.short_description,
productType: row.product_type,
riskProfile: row.risk_profile,
targetMonthlyReturn: row.target_monthly_return ? parseFloat(row.target_monthly_return) : null,
maxDrawdown: row.max_drawdown ? parseFloat(row.max_drawdown) : null,
guaranteedReturn: row.guaranteed_return,
managementFeePercent: parseFloat(row.management_fee_percent || '0'),
performanceFeePercent: parseFloat(row.performance_fee_percent || '0'),
profitSharePlatform: row.profit_share_platform ? parseFloat(row.profit_share_platform) : null,
profitShareClient: row.profit_share_client ? parseFloat(row.profit_share_client) : null,
minInvestment: parseFloat(row.min_investment),
maxInvestment: row.max_investment ? parseFloat(row.max_investment) : null,
minInvestmentPeriodDays: row.min_investment_period_days,
requiresKycLevel: row.requires_kyc_level,
allowedRiskProfiles: row.allowed_risk_profiles,
defaultBotId: row.default_bot_id,
isActive: row.is_active,
isVisible: row.is_visible,
termsUrl: row.terms_url,
riskDisclosureUrl: row.risk_disclosure_url,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
// ============================================================================
// Repository Class
// ============================================================================
class ProductRepository {
/**
* Create a new product
*/
async create(input: CreateProductInput): Promise<InvestmentProduct> {
const result = await db.query<ProductRow>(
`INSERT INTO investment.products (
name, slug, description, short_description, product_type, risk_profile,
target_monthly_return, max_drawdown, management_fee_percent, performance_fee_percent,
profit_share_platform, profit_share_client, min_investment, max_investment,
min_investment_period_days, requires_kyc_level, allowed_risk_profiles,
default_bot_id, terms_url, risk_disclosure_url
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
RETURNING *`,
[
input.name,
input.slug,
input.description || null,
input.shortDescription || null,
input.productType,
input.riskProfile,
input.targetMonthlyReturn ?? null,
input.maxDrawdown ?? null,
input.managementFeePercent ?? 0,
input.performanceFeePercent ?? 0,
input.profitSharePlatform ?? null,
input.profitShareClient ?? null,
input.minInvestment ?? 100,
input.maxInvestment ?? null,
input.minInvestmentPeriodDays ?? 30,
input.requiresKycLevel ?? 1,
input.allowedRiskProfiles || null,
input.defaultBotId || null,
input.termsUrl || null,
input.riskDisclosureUrl || null,
]
);
return mapRowToProduct(result.rows[0]);
}
/**
* Find product by ID
*/
async findById(id: string): Promise<InvestmentProduct | null> {
const result = await db.query<ProductRow>(
'SELECT * FROM investment.products WHERE id = $1',
[id]
);
if (result.rows.length === 0) {
return null;
}
return mapRowToProduct(result.rows[0]);
}
/**
* Find product by slug
*/
async findBySlug(slug: string): Promise<InvestmentProduct | null> {
const result = await db.query<ProductRow>(
'SELECT * FROM investment.products WHERE slug = $1',
[slug]
);
if (result.rows.length === 0) {
return null;
}
return mapRowToProduct(result.rows[0]);
}
/**
* Find all visible and active products
*/
async findAllVisible(): Promise<InvestmentProduct[]> {
const result = await db.query<ProductRow>(
`SELECT * FROM investment.products
WHERE is_active = true AND is_visible = true
ORDER BY min_investment ASC`
);
return result.rows.map(mapRowToProduct);
}
/**
* Find all products (including inactive)
*/
async findAll(): Promise<InvestmentProduct[]> {
const result = await db.query<ProductRow>(
`SELECT * FROM investment.products ORDER BY created_at DESC`
);
return result.rows.map(mapRowToProduct);
}
/**
* Find products by risk profile
*/
async findByRiskProfile(riskProfile: RiskProfile): Promise<InvestmentProduct[]> {
const result = await db.query<ProductRow>(
`SELECT * FROM investment.products
WHERE risk_profile = $1 AND is_active = true AND is_visible = true
ORDER BY min_investment ASC`,
[riskProfile]
);
return result.rows.map(mapRowToProduct);
}
/**
* Find products by type
*/
async findByType(productType: ProductType): Promise<InvestmentProduct[]> {
const result = await db.query<ProductRow>(
`SELECT * FROM investment.products
WHERE product_type = $1 AND is_active = true AND is_visible = true
ORDER BY min_investment ASC`,
[productType]
);
return result.rows.map(mapRowToProduct);
}
/**
* Update product
*/
async update(id: string, input: UpdateProductInput): Promise<InvestmentProduct | null> {
const updates: string[] = [];
const values: (string | number | boolean | RiskProfile[] | null)[] = [];
let paramIndex = 1;
if (input.name !== undefined) {
updates.push(`name = $${paramIndex++}`);
values.push(input.name);
}
if (input.description !== undefined) {
updates.push(`description = $${paramIndex++}`);
values.push(input.description);
}
if (input.shortDescription !== undefined) {
updates.push(`short_description = $${paramIndex++}`);
values.push(input.shortDescription);
}
if (input.targetMonthlyReturn !== undefined) {
updates.push(`target_monthly_return = $${paramIndex++}`);
values.push(input.targetMonthlyReturn);
}
if (input.maxDrawdown !== undefined) {
updates.push(`max_drawdown = $${paramIndex++}`);
values.push(input.maxDrawdown);
}
if (input.managementFeePercent !== undefined) {
updates.push(`management_fee_percent = $${paramIndex++}`);
values.push(input.managementFeePercent);
}
if (input.performanceFeePercent !== undefined) {
updates.push(`performance_fee_percent = $${paramIndex++}`);
values.push(input.performanceFeePercent);
}
if (input.profitSharePlatform !== undefined) {
updates.push(`profit_share_platform = $${paramIndex++}`);
values.push(input.profitSharePlatform);
}
if (input.profitShareClient !== undefined) {
updates.push(`profit_share_client = $${paramIndex++}`);
values.push(input.profitShareClient);
}
if (input.minInvestment !== undefined) {
updates.push(`min_investment = $${paramIndex++}`);
values.push(input.minInvestment);
}
if (input.maxInvestment !== undefined) {
updates.push(`max_investment = $${paramIndex++}`);
values.push(input.maxInvestment);
}
if (input.minInvestmentPeriodDays !== undefined) {
updates.push(`min_investment_period_days = $${paramIndex++}`);
values.push(input.minInvestmentPeriodDays);
}
if (input.requiresKycLevel !== undefined) {
updates.push(`requires_kyc_level = $${paramIndex++}`);
values.push(input.requiresKycLevel);
}
if (input.allowedRiskProfiles !== undefined) {
updates.push(`allowed_risk_profiles = $${paramIndex++}`);
values.push(input.allowedRiskProfiles);
}
if (input.defaultBotId !== undefined) {
updates.push(`default_bot_id = $${paramIndex++}`);
values.push(input.defaultBotId);
}
if (input.isActive !== undefined) {
updates.push(`is_active = $${paramIndex++}`);
values.push(input.isActive);
}
if (input.isVisible !== undefined) {
updates.push(`is_visible = $${paramIndex++}`);
values.push(input.isVisible);
}
if (input.termsUrl !== undefined) {
updates.push(`terms_url = $${paramIndex++}`);
values.push(input.termsUrl);
}
if (input.riskDisclosureUrl !== undefined) {
updates.push(`risk_disclosure_url = $${paramIndex++}`);
values.push(input.riskDisclosureUrl);
}
if (updates.length === 0) {
return this.findById(id);
}
updates.push(`updated_at = NOW()`);
values.push(id);
const result = await db.query<ProductRow>(
`UPDATE investment.products
SET ${updates.join(', ')}
WHERE id = $${paramIndex}
RETURNING *`,
values
);
if (result.rows.length === 0) {
return null;
}
return mapRowToProduct(result.rows[0]);
}
/**
* Activate a product
*/
async activate(id: string): Promise<InvestmentProduct | null> {
return this.update(id, { isActive: true });
}
/**
* Deactivate a product
*/
async deactivate(id: string): Promise<InvestmentProduct | null> {
return this.update(id, { isActive: false });
}
/**
* Show a product
*/
async show(id: string): Promise<InvestmentProduct | null> {
return this.update(id, { isVisible: true });
}
/**
* Hide a product
*/
async hide(id: string): Promise<InvestmentProduct | null> {
return this.update(id, { isVisible: false });
}
}
// Export singleton instance
export const productRepository = new ProductRepository();

View File

@ -0,0 +1,342 @@
/**
* Investment Withdrawal Repository
* Handles database operations for withdrawal requests
*/
import { db } from '../../../shared/database';
// ============================================================================
// Types
// ============================================================================
export type WithdrawalStatus = 'pending' | 'processing' | 'completed' | 'rejected';
export type DestinationType = 'wallet' | 'bank_transfer';
export interface WithdrawalRow {
id: string;
account_id: string;
user_id: string;
amount: string;
currency: string;
destination_type: DestinationType;
destination_wallet_id: string | null;
status: WithdrawalStatus;
processed_at: Date | null;
processed_by: string | null;
rejection_reason: string | null;
fee_amount: string;
net_amount: string | null;
transaction_id: string | null;
created_at: Date;
updated_at: Date;
}
export interface WithdrawalRequest {
id: string;
accountId: string;
userId: string;
amount: number;
currency: string;
destinationType: DestinationType;
destinationWalletId: string | null;
status: WithdrawalStatus;
processedAt: Date | null;
processedBy: string | null;
rejectionReason: string | null;
feeAmount: number;
netAmount: number | null;
transactionId: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface CreateWithdrawalInput {
accountId: string;
userId: string;
amount: number;
currency?: string;
destinationType: DestinationType;
destinationWalletId?: string;
feeAmount?: number;
}
export interface WithdrawalFilters {
accountId?: string;
userId?: string;
status?: WithdrawalStatus;
startDate?: Date;
endDate?: Date;
limit?: number;
offset?: number;
}
// ============================================================================
// Helper Functions
// ============================================================================
function mapRowToWithdrawal(row: WithdrawalRow): WithdrawalRequest {
return {
id: row.id,
accountId: row.account_id,
userId: row.user_id,
amount: parseFloat(row.amount),
currency: row.currency,
destinationType: row.destination_type,
destinationWalletId: row.destination_wallet_id,
status: row.status,
processedAt: row.processed_at,
processedBy: row.processed_by,
rejectionReason: row.rejection_reason,
feeAmount: parseFloat(row.fee_amount || '0'),
netAmount: row.net_amount ? parseFloat(row.net_amount) : null,
transactionId: row.transaction_id,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
// ============================================================================
// Repository Class
// ============================================================================
class WithdrawalRepository {
/**
* Create a new withdrawal request
*/
async create(input: CreateWithdrawalInput): Promise<WithdrawalRequest> {
const netAmount = input.amount - (input.feeAmount || 0);
const result = await db.query<WithdrawalRow>(
`INSERT INTO investment.withdrawal_requests (
account_id, user_id, amount, currency, destination_type,
destination_wallet_id, fee_amount, net_amount, status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending')
RETURNING *`,
[
input.accountId,
input.userId,
input.amount,
input.currency || 'USD',
input.destinationType,
input.destinationWalletId || null,
input.feeAmount || 0,
netAmount,
]
);
return mapRowToWithdrawal(result.rows[0]);
}
/**
* Find withdrawal by ID
*/
async findById(id: string): Promise<WithdrawalRequest | null> {
const result = await db.query<WithdrawalRow>(
'SELECT * FROM investment.withdrawal_requests WHERE id = $1',
[id]
);
if (result.rows.length === 0) {
return null;
}
return mapRowToWithdrawal(result.rows[0]);
}
/**
* Find withdrawals by user
*/
async findByUserId(
userId: string,
status?: WithdrawalStatus
): Promise<WithdrawalRequest[]> {
let query = `SELECT * FROM investment.withdrawal_requests WHERE user_id = $1`;
const params: (string | WithdrawalStatus)[] = [userId];
if (status) {
query += ` AND status = $2`;
params.push(status);
}
query += ` ORDER BY created_at DESC`;
const result = await db.query<WithdrawalRow>(query, params);
return result.rows.map(mapRowToWithdrawal);
}
/**
* Find withdrawals by account
*/
async findByAccountId(accountId: string): Promise<WithdrawalRequest[]> {
const result = await db.query<WithdrawalRow>(
`SELECT * FROM investment.withdrawal_requests
WHERE account_id = $1
ORDER BY created_at DESC`,
[accountId]
);
return result.rows.map(mapRowToWithdrawal);
}
/**
* Find all withdrawals with filters
*/
async findAll(filters: WithdrawalFilters = {}): Promise<{
withdrawals: WithdrawalRequest[];
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.userId) {
conditions.push(`user_id = $${paramIndex++}`);
values.push(filters.userId);
}
if (filters.status) {
conditions.push(`status = $${paramIndex++}`);
values.push(filters.status);
}
if (filters.startDate) {
conditions.push(`created_at >= $${paramIndex++}`);
values.push(filters.startDate);
}
if (filters.endDate) {
conditions.push(`created_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.withdrawal_requests ${whereClause}`,
values
);
const total = parseInt(countResult.rows[0].count);
// Get withdrawals
let query = `
SELECT * FROM investment.withdrawal_requests
${whereClause}
ORDER BY created_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<WithdrawalRow>(query, values);
return {
withdrawals: result.rows.map(mapRowToWithdrawal),
total,
};
}
/**
* Update withdrawal status to processing
*/
async process(id: string, processedBy: string): Promise<WithdrawalRequest | null> {
const result = await db.query<WithdrawalRow>(
`UPDATE investment.withdrawal_requests
SET status = 'processing', processed_at = NOW(), processed_by = $2, updated_at = NOW()
WHERE id = $1 AND status = 'pending'
RETURNING *`,
[id, processedBy]
);
if (result.rows.length === 0) {
return null;
}
return mapRowToWithdrawal(result.rows[0]);
}
/**
* Complete a withdrawal
*/
async complete(id: string, transactionId?: string): Promise<WithdrawalRequest | null> {
const result = await db.query<WithdrawalRow>(
`UPDATE investment.withdrawal_requests
SET status = 'completed', transaction_id = $2, updated_at = NOW()
WHERE id = $1 AND status = 'processing'
RETURNING *`,
[id, transactionId || null]
);
if (result.rows.length === 0) {
return null;
}
return mapRowToWithdrawal(result.rows[0]);
}
/**
* Reject a withdrawal
*/
async reject(
id: string,
rejectionReason: string,
processedBy: string
): Promise<WithdrawalRequest | null> {
const result = await db.query<WithdrawalRow>(
`UPDATE investment.withdrawal_requests
SET status = 'rejected', rejection_reason = $2, processed_by = $3,
processed_at = NOW(), updated_at = NOW()
WHERE id = $1 AND status IN ('pending', 'processing')
RETURNING *`,
[id, rejectionReason, processedBy]
);
if (result.rows.length === 0) {
return null;
}
return mapRowToWithdrawal(result.rows[0]);
}
/**
* Get daily withdrawal total for a user
*/
async getDailyTotal(userId: string): Promise<number> {
const result = await db.query<{ total: string }>(
`SELECT COALESCE(SUM(amount), 0) as total
FROM investment.withdrawal_requests
WHERE user_id = $1
AND status NOT IN ('rejected')
AND created_at >= CURRENT_DATE`,
[userId]
);
return parseFloat(result.rows[0].total);
}
/**
* Get pending withdrawals count
*/
async getPendingCount(): Promise<number> {
const result = await db.query<{ count: string }>(
`SELECT COUNT(*) as count FROM investment.withdrawal_requests WHERE status = 'pending'`
);
return parseInt(result.rows[0].count);
}
}
// Export singleton instance
export const withdrawalRepository = new WithdrawalRepository();

View File

@ -1,9 +1,16 @@
/** /**
* Investment Product Service * Investment Product Service
* Manages investment products (Atlas, Orion, Nova) * Manages investment products (Atlas, Orion, Nova)
*
* Supports both PostgreSQL repository and in-memory fallback for defaults
*/ */
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import {
productRepository,
InvestmentProduct as RepoProduct,
} from '../repositories/product.repository';
import { RiskProfile as RepoRiskProfile } from '../repositories/account.repository';
// ============================================================================ // ============================================================================
// Types // Types
@ -32,7 +39,33 @@ export interface InvestmentProduct {
} }
// ============================================================================ // ============================================================================
// Default Products // Helper Functions
// ============================================================================
function mapRepoToService(repo: RepoProduct): InvestmentProduct {
return {
id: repo.id,
code: repo.slug,
name: repo.name,
description: repo.description || '',
riskProfile: repo.riskProfile as RiskProfile,
targetReturnMin: repo.targetMonthlyReturn || 0,
targetReturnMax: (repo.targetMonthlyReturn || 0) * 1.5,
maxDrawdown: repo.maxDrawdown || 10,
minInvestment: repo.minInvestment,
managementFee: repo.managementFeePercent,
performanceFee: repo.performanceFeePercent,
isActive: repo.isActive,
features: [], // Features stored in description
strategy: repo.shortDescription || '',
assets: [], // Assets from product type
tradingFrequency: '',
createdAt: repo.createdAt,
};
}
// ============================================================================
// Default Products (fallback when DB is empty)
// ============================================================================ // ============================================================================
const DEFAULT_PRODUCTS: InvestmentProduct[] = [ const DEFAULT_PRODUCTS: InvestmentProduct[] = [
@ -113,11 +146,8 @@ const DEFAULT_PRODUCTS: InvestmentProduct[] = [
}, },
]; ];
// ============================================================================ // In-memory fallback storage
// In-Memory Storage const defaultProducts: Map<string, InvestmentProduct> = new Map(
// ============================================================================
const products: Map<string, InvestmentProduct> = new Map(
DEFAULT_PRODUCTS.map((p) => [p.id, p]) DEFAULT_PRODUCTS.map((p) => [p.id, p])
); );
@ -126,32 +156,80 @@ const products: Map<string, InvestmentProduct> = new Map(
// ============================================================================ // ============================================================================
class ProductService { class ProductService {
private useDatabase = true; // Toggle for DB vs in-memory
/** /**
* Get all active products * Get all active products
*/ */
async getProducts(): Promise<InvestmentProduct[]> { async getProducts(): Promise<InvestmentProduct[]> {
return Array.from(products.values()).filter((p) => p.isActive); if (this.useDatabase) {
try {
const repoProducts = await productRepository.findAllVisible();
if (repoProducts.length > 0) {
return repoProducts.map(mapRepoToService);
}
} catch {
// Fall back to in-memory on DB error
}
}
return Array.from(defaultProducts.values()).filter((p) => p.isActive);
} }
/** /**
* Get product by ID * Get product by ID
*/ */
async getProductById(id: string): Promise<InvestmentProduct | null> { async getProductById(id: string): Promise<InvestmentProduct | null> {
return products.get(id) || null; if (this.useDatabase) {
try {
const repo = await productRepository.findById(id);
if (repo) {
return mapRepoToService(repo);
}
} catch {
// Fall back to in-memory on DB error
}
}
return defaultProducts.get(id) || null;
} }
/** /**
* Get product by code * Get product by code (slug)
*/ */
async getProductByCode(code: string): Promise<InvestmentProduct | null> { async getProductByCode(code: string): Promise<InvestmentProduct | null> {
return Array.from(products.values()).find((p) => p.code === code) || null; if (this.useDatabase) {
try {
const repo = await productRepository.findBySlug(code);
if (repo) {
return mapRepoToService(repo);
}
} catch {
// Fall back to in-memory on DB error
}
}
return Array.from(defaultProducts.values()).find((p) => p.code === code) || null;
} }
/** /**
* Get products by risk profile * Get products by risk profile
*/ */
async getProductsByRiskProfile(riskProfile: RiskProfile): Promise<InvestmentProduct[]> { async getProductsByRiskProfile(riskProfile: RiskProfile): Promise<InvestmentProduct[]> {
return Array.from(products.values()).filter( if (this.useDatabase) {
try {
const repoProducts = await productRepository.findByRiskProfile(
riskProfile as RepoRiskProfile
);
if (repoProducts.length > 0) {
return repoProducts.map(mapRepoToService);
}
} catch {
// Fall back to in-memory on DB error
}
}
return Array.from(defaultProducts.values()).filter(
(p) => p.riskProfile === riskProfile && p.isActive (p) => p.riskProfile === riskProfile && p.isActive
); );
} }
@ -162,12 +240,33 @@ class ProductService {
async createProduct( async createProduct(
input: Omit<InvestmentProduct, 'id' | 'createdAt'> input: Omit<InvestmentProduct, 'id' | 'createdAt'>
): Promise<InvestmentProduct> { ): Promise<InvestmentProduct> {
if (this.useDatabase) {
try {
const repo = await productRepository.create({
name: input.name,
slug: input.code,
description: input.description,
shortDescription: input.strategy,
productType: 'variable_return',
riskProfile: input.riskProfile as RepoRiskProfile,
targetMonthlyReturn: input.targetReturnMin,
maxDrawdown: input.maxDrawdown,
managementFeePercent: input.managementFee,
performanceFeePercent: input.performanceFee,
minInvestment: input.minInvestment,
});
return mapRepoToService(repo);
} catch {
// Fall back to in-memory on DB error
}
}
const product: InvestmentProduct = { const product: InvestmentProduct = {
...input, ...input,
id: uuidv4(), id: uuidv4(),
createdAt: new Date(), createdAt: new Date(),
}; };
products.set(product.id, product); defaultProducts.set(product.id, product);
return product; return product;
} }
@ -178,11 +277,32 @@ class ProductService {
id: string, id: string,
updates: Partial<Omit<InvestmentProduct, 'id' | 'createdAt'>> updates: Partial<Omit<InvestmentProduct, 'id' | 'createdAt'>>
): Promise<InvestmentProduct | null> { ): Promise<InvestmentProduct | null> {
const product = products.get(id); if (this.useDatabase) {
try {
const repo = await productRepository.update(id, {
name: updates.name,
description: updates.description,
shortDescription: updates.strategy,
targetMonthlyReturn: updates.targetReturnMin,
maxDrawdown: updates.maxDrawdown,
managementFeePercent: updates.managementFee,
performanceFeePercent: updates.performanceFee,
minInvestment: updates.minInvestment,
isActive: updates.isActive,
});
if (repo) {
return mapRepoToService(repo);
}
} catch {
// Fall back to in-memory on DB error
}
}
const product = defaultProducts.get(id);
if (!product) return null; if (!product) return null;
const updated = { ...product, ...updates }; const updated = { ...product, ...updates };
products.set(id, updated); defaultProducts.set(id, updated);
return updated; return updated;
} }
@ -190,7 +310,18 @@ class ProductService {
* Deactivate a product (admin only) * Deactivate a product (admin only)
*/ */
async deactivateProduct(id: string): Promise<boolean> { async deactivateProduct(id: string): Promise<boolean> {
const product = products.get(id); if (this.useDatabase) {
try {
const repo = await productRepository.deactivate(id);
if (repo) {
return true;
}
} catch {
// Fall back to in-memory on DB error
}
}
const product = defaultProducts.get(id);
if (!product) return false; if (!product) return false;
product.isActive = false; product.isActive = false;
@ -206,7 +337,7 @@ class ProductService {
avgReturn: number; avgReturn: number;
winRate: number; winRate: number;
}> { }> {
// TODO: Calculate from real data // TODO: Calculate from real data using account repository
return { return {
totalInvestors: Math.floor(Math.random() * 1000) + 100, totalInvestors: Math.floor(Math.random() * 1000) + 100,
totalAum: Math.floor(Math.random() * 10000000) + 1000000, totalAum: Math.floor(Math.random() * 10000000) + 1000000,
@ -219,9 +350,10 @@ class ProductService {
* Get product performance history * Get product performance history
*/ */
async getProductPerformance( async getProductPerformance(
productId: string, _productId: string,
period: 'week' | 'month' | '3months' | 'year' period: 'week' | 'month' | '3months' | 'year'
): Promise<{ date: string; return: number }[]> { ): Promise<{ date: string; return: number }[]> {
// TODO: Calculate from real performance data
const days = const days =
period === 'week' ? 7 : period === 'month' ? 30 : period === '3months' ? 90 : 365; period === 'week' ? 7 : period === 'month' ? 30 : period === '3months' ? 90 : 365;

View File

@ -5,7 +5,6 @@
* Now uses PostgreSQL repository for transactions * Now uses PostgreSQL repository for transactions
*/ */
import { v4 as uuidv4 } from 'uuid';
import { accountService } from './account.service'; import { accountService } from './account.service';
import { import {
transactionRepository, transactionRepository,
@ -13,6 +12,16 @@ import {
TransactionType as RepoTransactionType, TransactionType as RepoTransactionType,
TransactionStatus as RepoTransactionStatus, TransactionStatus as RepoTransactionStatus,
} from '../repositories/transaction.repository'; } from '../repositories/transaction.repository';
import {
withdrawalRepository,
WithdrawalRequest as RepoWithdrawal,
WithdrawalStatus as RepoWithdrawalStatus,
} from '../repositories/withdrawal.repository';
import {
distributionRepository,
Distribution as RepoDistribution,
DistributionStatus as RepoDistributionStatus,
} from '../repositories/distribution.repository';
// ============================================================================ // ============================================================================
// Types // Types
@ -130,13 +139,44 @@ export interface CreateWithdrawalInput {
} }
// ============================================================================ // ============================================================================
// In-Memory Storage (for entities not yet migrated to PostgreSQL) // Helper Functions for Withdrawal Mapping
// ============================================================================ // ============================================================================
// Note: Transactions now use PostgreSQL via transactionRepository function mapRepoWithdrawalToService(repo: RepoWithdrawal): WithdrawalRequest {
// Withdrawal requests and distributions will use in-memory until their repositories are created return {
const withdrawalRequests: Map<string, WithdrawalRequest> = new Map(); id: repo.id,
const distributions: Map<string, Distribution> = new Map(); accountId: repo.accountId,
userId: repo.userId,
amount: repo.amount,
status: repo.status as WithdrawalStatus,
bankInfo: null, // Bank info stored in metadata if needed
cryptoInfo: null, // Crypto info stored in metadata if needed
rejectionReason: repo.rejectionReason,
requestedAt: repo.createdAt,
processedAt: repo.processedAt,
completedAt: repo.status === 'completed' ? repo.processedAt : null,
};
}
// ============================================================================
// Helper Functions for Distribution Mapping
// ============================================================================
function mapRepoDistributionToService(repo: RepoDistribution, userId: string): Distribution {
return {
id: repo.id,
accountId: repo.accountId,
userId,
periodStart: repo.periodStart,
periodEnd: repo.periodEnd,
grossEarnings: repo.grossProfit,
performanceFee: repo.managementFee,
netEarnings: repo.clientAmount,
status: repo.status === 'distributed' ? 'distributed' : 'pending',
distributedAt: repo.distributedAt,
createdAt: repo.createdAt,
};
}
// ============================================================================ // ============================================================================
// Transaction Service // Transaction Service
@ -293,22 +333,22 @@ class TransactionService {
userId: string, userId: string,
status?: WithdrawalStatus status?: WithdrawalStatus
): Promise<WithdrawalRequest[]> { ): Promise<WithdrawalRequest[]> {
let userWithdrawals = Array.from(withdrawalRequests.values()) const repoWithdrawals = await withdrawalRepository.findByUserId(
.filter((w) => w.userId === userId) userId,
.sort((a, b) => b.requestedAt.getTime() - a.requestedAt.getTime()); status as RepoWithdrawalStatus | undefined
);
if (status) { return repoWithdrawals.map(mapRepoWithdrawalToService);
userWithdrawals = userWithdrawals.filter((w) => w.status === status);
}
return userWithdrawals;
} }
/** /**
* Get withdrawal request by ID * Get withdrawal request by ID
*/ */
async getWithdrawalById(withdrawalId: string): Promise<WithdrawalRequest | null> { async getWithdrawalById(withdrawalId: string): Promise<WithdrawalRequest | null> {
return withdrawalRequests.get(withdrawalId) || null; const repo = await withdrawalRepository.findById(withdrawalId);
if (!repo) return null;
return mapRepoWithdrawalToService(repo);
} }
/** /**
@ -343,39 +383,27 @@ class TransactionService {
throw new Error('Bank or crypto information is required'); throw new Error('Bank or crypto information is required');
} }
// Check daily withdrawal limit // Check daily withdrawal limit using repository
const dailyLimit = 10000; const dailyLimit = 10000;
const todayWithdrawals = Array.from(withdrawalRequests.values()) const todayWithdrawals = await withdrawalRepository.getDailyTotal(userId);
.filter((w) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return (
w.userId === userId &&
w.status !== 'rejected' &&
w.requestedAt >= today
);
})
.reduce((sum, w) => sum + w.amount, 0);
if (todayWithdrawals + input.amount > dailyLimit) { if (todayWithdrawals + input.amount > dailyLimit) {
throw new Error(`Daily withdrawal limit of $${dailyLimit} exceeded`); throw new Error(`Daily withdrawal limit of $${dailyLimit} exceeded`);
} }
const withdrawal: WithdrawalRequest = { // Create withdrawal request in database
id: uuidv4(), const destinationType = input.bankInfo ? 'bank_transfer' : 'wallet';
const repo = await withdrawalRepository.create({
accountId: input.accountId, accountId: input.accountId,
userId, userId,
amount: input.amount, amount: input.amount,
status: 'pending', destinationType,
bankInfo: input.bankInfo || null, });
cryptoInfo: input.cryptoInfo || null,
rejectionReason: null,
requestedAt: new Date(),
processedAt: null,
completedAt: null,
};
withdrawalRequests.set(withdrawal.id, withdrawal); const withdrawal = mapRepoWithdrawalToService(repo);
// Add bank/crypto info to the response
withdrawal.bankInfo = input.bankInfo || null;
withdrawal.cryptoInfo = input.cryptoInfo || null;
// Create pending transaction in database // Create pending transaction in database
await transactionRepository.create({ await transactionRepository.create({
@ -393,59 +421,63 @@ class TransactionService {
/** /**
* Process a withdrawal (admin) * Process a withdrawal (admin)
*/ */
async processWithdrawal(withdrawalId: string): Promise<WithdrawalRequest> { async processWithdrawal(withdrawalId: string, processedBy: string = 'system'): Promise<WithdrawalRequest> {
const withdrawal = withdrawalRequests.get(withdrawalId); const repo = await withdrawalRepository.findById(withdrawalId);
if (!withdrawal) { if (!repo) {
throw new Error(`Withdrawal not found: ${withdrawalId}`); throw new Error(`Withdrawal not found: ${withdrawalId}`);
} }
if (withdrawal.status !== 'pending') { if (repo.status !== 'pending') {
throw new Error('Withdrawal is not pending'); throw new Error('Withdrawal is not pending');
} }
withdrawal.status = 'processing'; const updated = await withdrawalRepository.process(withdrawalId, processedBy);
withdrawal.processedAt = new Date(); if (!updated) {
throw new Error('Failed to process withdrawal');
}
return withdrawal; return mapRepoWithdrawalToService(updated);
} }
/** /**
* Complete a withdrawal (admin) * Complete a withdrawal (admin)
*/ */
async completeWithdrawal(withdrawalId: string): Promise<WithdrawalRequest> { async completeWithdrawal(withdrawalId: string): Promise<WithdrawalRequest> {
const withdrawal = withdrawalRequests.get(withdrawalId); const repo = await withdrawalRepository.findById(withdrawalId);
if (!withdrawal) { if (!repo) {
throw new Error(`Withdrawal not found: ${withdrawalId}`); throw new Error(`Withdrawal not found: ${withdrawalId}`);
} }
if (withdrawal.status !== 'processing') { if (repo.status !== 'processing') {
throw new Error('Withdrawal is not being processed'); throw new Error('Withdrawal is not being processed');
} }
// Deduct from account using accountService.withdraw // Deduct from account using accountService.withdraw
const updatedAccount = await accountService.withdraw(withdrawal.accountId, withdrawal.amount); const updatedAccount = await accountService.withdraw(repo.accountId, repo.amount);
// Update withdrawal // Complete withdrawal in database
withdrawal.status = 'completed'; const completed = await withdrawalRepository.complete(withdrawalId);
withdrawal.completedAt = new Date(); if (!completed) {
throw new Error('Failed to complete withdrawal');
}
// Find and update the pending transaction in database // Find and update the pending transaction in database
const { transactions: pendingTxs } = await transactionRepository.findByAccountId( const { transactions: pendingTxs } = await transactionRepository.findByAccountId(
withdrawal.accountId, repo.accountId,
{ transactionType: 'withdrawal', status: 'pending' } { transactionType: 'withdrawal', status: 'pending' }
); );
const pendingTx = pendingTxs.find((t) => t.amount === withdrawal.amount); const pendingTx = pendingTxs.find((t) => t.amount === repo.amount);
if (pendingTx) { if (pendingTx) {
await transactionRepository.updateStatus(pendingTx.id, 'completed', { await transactionRepository.updateStatus(pendingTx.id, 'completed', {
balanceAfter: updatedAccount.balance, balanceAfter: updatedAccount.balance,
}); });
await transactionRepository.update(pendingTx.id, { await transactionRepository.update(pendingTx.id, {
notes: `Withdrawal of $${withdrawal.amount.toFixed(2)}`, notes: `Withdrawal of $${repo.amount.toFixed(2)}`,
}); });
} }
return withdrawal; return mapRepoWithdrawalToService(completed);
} }
/** /**
@ -453,28 +485,31 @@ class TransactionService {
*/ */
async rejectWithdrawal( async rejectWithdrawal(
withdrawalId: string, withdrawalId: string,
reason: string reason: string,
processedBy: string = 'system'
): Promise<WithdrawalRequest> { ): Promise<WithdrawalRequest> {
const withdrawal = withdrawalRequests.get(withdrawalId); const repo = await withdrawalRepository.findById(withdrawalId);
if (!withdrawal) { if (!repo) {
throw new Error(`Withdrawal not found: ${withdrawalId}`); throw new Error(`Withdrawal not found: ${withdrawalId}`);
} }
if (withdrawal.status === 'completed') { if (repo.status === 'completed') {
throw new Error('Cannot reject completed withdrawal'); throw new Error('Cannot reject completed withdrawal');
} }
withdrawal.status = 'rejected'; // Reject withdrawal in database
withdrawal.rejectionReason = reason; const rejected = await withdrawalRepository.reject(withdrawalId, reason, processedBy);
withdrawal.processedAt = new Date(); if (!rejected) {
throw new Error('Failed to reject withdrawal');
}
// Find and cancel the pending transaction in database // Find and cancel the pending transaction in database
const { transactions: pendingTxs } = await transactionRepository.findByAccountId( const { transactions: pendingTxs } = await transactionRepository.findByAccountId(
withdrawal.accountId, repo.accountId,
{ transactionType: 'withdrawal', status: 'pending' } { transactionType: 'withdrawal', status: 'pending' }
); );
const pendingTx = pendingTxs.find((t) => t.amount === withdrawal.amount); const pendingTx = pendingTxs.find((t) => t.amount === repo.amount);
if (pendingTx) { if (pendingTx) {
await transactionRepository.updateStatus(pendingTx.id, 'cancelled', { await transactionRepository.updateStatus(pendingTx.id, 'cancelled', {
failureReason: reason, failureReason: reason,
@ -484,7 +519,7 @@ class TransactionService {
}); });
} }
return withdrawal; return mapRepoWithdrawalToService(rejected);
} }
// ========================================================================== // ==========================================================================
@ -495,9 +530,11 @@ class TransactionService {
* Get distributions for an account * Get distributions for an account
*/ */
async getAccountDistributions(accountId: string): Promise<Distribution[]> { async getAccountDistributions(accountId: string): Promise<Distribution[]> {
return Array.from(distributions.values()) const account = await accountService.getAccountById(accountId);
.filter((d) => d.accountId === accountId) const userId = account?.userId || '';
.sort((a, b) => b.periodEnd.getTime() - a.periodEnd.getTime());
const repoDistributions = await distributionRepository.findByAccountId(accountId);
return repoDistributions.map((d) => mapRepoDistributionToService(d, userId));
} }
/** /**
@ -515,82 +552,105 @@ class TransactionService {
throw new Error(`Account not found: ${accountId}`); throw new Error(`Account not found: ${accountId}`);
} }
const performanceFee = grossEarnings * (performanceFeePercent / 100); // Platform takes performance fee, client gets the rest
const netEarnings = grossEarnings - performanceFee; const platformSharePercent = performanceFeePercent;
const clientSharePercent = 100 - performanceFeePercent;
const distribution: Distribution = { const repo = await distributionRepository.create({
id: uuidv4(),
accountId, accountId,
userId: account.userId,
periodStart, periodStart,
periodEnd, periodEnd,
grossEarnings, grossProfit: grossEarnings,
performanceFee, managementFee: 0,
netEarnings, platformSharePercent,
status: 'pending', clientSharePercent,
distributedAt: null, });
createdAt: new Date(),
};
distributions.set(distribution.id, distribution); return mapRepoDistributionToService(repo, account.userId);
return distribution;
} }
/** /**
* Distribute earnings * Distribute earnings
*/ */
async distributeEarnings(distributionId: string): Promise<Distribution> { async distributeEarnings(distributionId: string): Promise<Distribution> {
const distribution = distributions.get(distributionId); const repo = await distributionRepository.findById(distributionId);
if (!distribution) { if (!repo) {
throw new Error(`Distribution not found: ${distributionId}`); throw new Error(`Distribution not found: ${distributionId}`);
} }
if (distribution.status === 'distributed') { if (repo.status === 'distributed') {
throw new Error('Distribution already completed'); throw new Error('Distribution already completed');
} }
// Get account balance before earnings const account = await accountService.getAccountById(repo.accountId);
const accountBefore = await accountService.getAccountById(distribution.accountId); if (!account) {
const balanceBefore = accountBefore?.balance || 0; throw new Error(`Account not found: ${repo.accountId}`);
}
// Record earnings in account // Get account balance before earnings
const balanceBefore = account.balance;
// Record earnings in account (client amount)
await accountService.recordEarnings( await accountService.recordEarnings(
distribution.accountId, repo.accountId,
distribution.grossEarnings, repo.grossProfit,
distribution.performanceFee repo.platformAmount
); );
// Get updated account // Get updated account
const account = await accountService.getAccountById(distribution.accountId); const updatedAccount = await accountService.getAccountById(repo.accountId);
// Create distribution transaction in database (net earnings) // Create distribution transaction in database (client earnings)
if (distribution.netEarnings !== 0) { if (repo.clientAmount !== 0) {
const earningTx = await transactionRepository.create({ const earningTx = await transactionRepository.create({
accountId: distribution.accountId, accountId: repo.accountId,
transactionType: 'distribution', transactionType: 'distribution',
amount: distribution.netEarnings, amount: repo.clientAmount,
balanceBefore, balanceBefore,
balanceAfter: account?.balance, balanceAfter: updatedAccount?.balance,
distributionId, distributionId,
notes: `Earnings for ${distribution.periodStart.toLocaleDateString()} - ${distribution.periodEnd.toLocaleDateString()}`, notes: `Earnings for ${repo.periodStart.toLocaleDateString()} - ${repo.periodEnd.toLocaleDateString()}`,
}); });
// Mark as completed immediately // Mark as completed immediately
await transactionRepository.updateStatus(earningTx.id, 'completed'); await transactionRepository.updateStatus(earningTx.id, 'completed');
} }
distribution.status = 'distributed'; // Mark distribution as distributed
distribution.distributedAt = new Date(); const distributed = await distributionRepository.distribute(distributionId);
if (!distributed) {
throw new Error('Failed to mark distribution as distributed');
}
return distribution; return mapRepoDistributionToService(distributed, account.userId);
} }
/** /**
* Get pending distributions * Get pending distributions
*/ */
async getPendingDistributions(): Promise<Distribution[]> { async getPendingDistributions(): Promise<Distribution[]> {
return Array.from(distributions.values()) const pendingDistributions = await distributionRepository.findPending();
.filter((d) => d.status === 'pending')
.sort((a, b) => a.periodEnd.getTime() - b.periodEnd.getTime()); // Get user IDs for each distribution
const results: Distribution[] = [];
for (const repo of pendingDistributions) {
const account = await accountService.getAccountById(repo.accountId);
const userId = account?.userId || '';
results.push(mapRepoDistributionToService(repo, userId));
}
return results;
}
/**
* Get distribution statistics
*/
async getDistributionStats(): Promise<{
pendingCount: number;
pendingAmount: number;
distributedCount: number;
distributedAmount: number;
}> {
return distributionRepository.getStats();
} }
} }