[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:
parent
3df1ed1f94
commit
4322caf69a
354
src/modules/investment/repositories/distribution.repository.ts
Normal file
354
src/modules/investment/repositories/distribution.repository.ts
Normal 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();
|
||||||
@ -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';
|
||||||
|
|||||||
437
src/modules/investment/repositories/product.repository.ts
Normal file
437
src/modules/investment/repositories/product.repository.ts
Normal 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();
|
||||||
342
src/modules/investment/repositories/withdrawal.repository.ts
Normal file
342
src/modules/investment/repositories/withdrawal.repository.ts
Normal 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();
|
||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user