[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 './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
|
||||
* Manages investment products (Atlas, Orion, Nova)
|
||||
*
|
||||
* Supports both PostgreSQL repository and in-memory fallback for defaults
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
productRepository,
|
||||
InvestmentProduct as RepoProduct,
|
||||
} from '../repositories/product.repository';
|
||||
import { RiskProfile as RepoRiskProfile } from '../repositories/account.repository';
|
||||
|
||||
// ============================================================================
|
||||
// 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[] = [
|
||||
@ -113,11 +146,8 @@ const DEFAULT_PRODUCTS: InvestmentProduct[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// In-Memory Storage
|
||||
// ============================================================================
|
||||
|
||||
const products: Map<string, InvestmentProduct> = new Map(
|
||||
// In-memory fallback storage
|
||||
const defaultProducts: Map<string, InvestmentProduct> = new Map(
|
||||
DEFAULT_PRODUCTS.map((p) => [p.id, p])
|
||||
);
|
||||
|
||||
@ -126,32 +156,80 @@ const products: Map<string, InvestmentProduct> = new Map(
|
||||
// ============================================================================
|
||||
|
||||
class ProductService {
|
||||
private useDatabase = true; // Toggle for DB vs in-memory
|
||||
|
||||
/**
|
||||
* Get all active products
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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> {
|
||||
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
|
||||
*/
|
||||
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
|
||||
);
|
||||
}
|
||||
@ -162,12 +240,33 @@ class ProductService {
|
||||
async createProduct(
|
||||
input: Omit<InvestmentProduct, 'id' | 'createdAt'>
|
||||
): 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 = {
|
||||
...input,
|
||||
id: uuidv4(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
products.set(product.id, product);
|
||||
defaultProducts.set(product.id, product);
|
||||
return product;
|
||||
}
|
||||
|
||||
@ -178,11 +277,32 @@ class ProductService {
|
||||
id: string,
|
||||
updates: Partial<Omit<InvestmentProduct, 'id' | 'createdAt'>>
|
||||
): 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;
|
||||
|
||||
const updated = { ...product, ...updates };
|
||||
products.set(id, updated);
|
||||
defaultProducts.set(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@ -190,7 +310,18 @@ class ProductService {
|
||||
* Deactivate a product (admin only)
|
||||
*/
|
||||
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;
|
||||
|
||||
product.isActive = false;
|
||||
@ -206,7 +337,7 @@ class ProductService {
|
||||
avgReturn: number;
|
||||
winRate: number;
|
||||
}> {
|
||||
// TODO: Calculate from real data
|
||||
// TODO: Calculate from real data using account repository
|
||||
return {
|
||||
totalInvestors: Math.floor(Math.random() * 1000) + 100,
|
||||
totalAum: Math.floor(Math.random() * 10000000) + 1000000,
|
||||
@ -219,9 +350,10 @@ class ProductService {
|
||||
* Get product performance history
|
||||
*/
|
||||
async getProductPerformance(
|
||||
productId: string,
|
||||
_productId: string,
|
||||
period: 'week' | 'month' | '3months' | 'year'
|
||||
): Promise<{ date: string; return: number }[]> {
|
||||
// TODO: Calculate from real performance data
|
||||
const days =
|
||||
period === 'week' ? 7 : period === 'month' ? 30 : period === '3months' ? 90 : 365;
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
* Now uses PostgreSQL repository for transactions
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { accountService } from './account.service';
|
||||
import {
|
||||
transactionRepository,
|
||||
@ -13,6 +12,16 @@ import {
|
||||
TransactionType as RepoTransactionType,
|
||||
TransactionStatus as RepoTransactionStatus,
|
||||
} 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
|
||||
@ -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
|
||||
// Withdrawal requests and distributions will use in-memory until their repositories are created
|
||||
const withdrawalRequests: Map<string, WithdrawalRequest> = new Map();
|
||||
const distributions: Map<string, Distribution> = new Map();
|
||||
function mapRepoWithdrawalToService(repo: RepoWithdrawal): WithdrawalRequest {
|
||||
return {
|
||||
id: repo.id,
|
||||
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
|
||||
@ -293,22 +333,22 @@ class TransactionService {
|
||||
userId: string,
|
||||
status?: WithdrawalStatus
|
||||
): Promise<WithdrawalRequest[]> {
|
||||
let userWithdrawals = Array.from(withdrawalRequests.values())
|
||||
.filter((w) => w.userId === userId)
|
||||
.sort((a, b) => b.requestedAt.getTime() - a.requestedAt.getTime());
|
||||
const repoWithdrawals = await withdrawalRepository.findByUserId(
|
||||
userId,
|
||||
status as RepoWithdrawalStatus | undefined
|
||||
);
|
||||
|
||||
if (status) {
|
||||
userWithdrawals = userWithdrawals.filter((w) => w.status === status);
|
||||
}
|
||||
|
||||
return userWithdrawals;
|
||||
return repoWithdrawals.map(mapRepoWithdrawalToService);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get withdrawal request by ID
|
||||
*/
|
||||
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');
|
||||
}
|
||||
|
||||
// Check daily withdrawal limit
|
||||
// Check daily withdrawal limit using repository
|
||||
const dailyLimit = 10000;
|
||||
const todayWithdrawals = Array.from(withdrawalRequests.values())
|
||||
.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);
|
||||
const todayWithdrawals = await withdrawalRepository.getDailyTotal(userId);
|
||||
|
||||
if (todayWithdrawals + input.amount > dailyLimit) {
|
||||
throw new Error(`Daily withdrawal limit of $${dailyLimit} exceeded`);
|
||||
}
|
||||
|
||||
const withdrawal: WithdrawalRequest = {
|
||||
id: uuidv4(),
|
||||
// Create withdrawal request in database
|
||||
const destinationType = input.bankInfo ? 'bank_transfer' : 'wallet';
|
||||
const repo = await withdrawalRepository.create({
|
||||
accountId: input.accountId,
|
||||
userId,
|
||||
amount: input.amount,
|
||||
status: 'pending',
|
||||
bankInfo: input.bankInfo || null,
|
||||
cryptoInfo: input.cryptoInfo || null,
|
||||
rejectionReason: null,
|
||||
requestedAt: new Date(),
|
||||
processedAt: null,
|
||||
completedAt: null,
|
||||
};
|
||||
destinationType,
|
||||
});
|
||||
|
||||
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
|
||||
await transactionRepository.create({
|
||||
@ -393,59 +421,63 @@ class TransactionService {
|
||||
/**
|
||||
* Process a withdrawal (admin)
|
||||
*/
|
||||
async processWithdrawal(withdrawalId: string): Promise<WithdrawalRequest> {
|
||||
const withdrawal = withdrawalRequests.get(withdrawalId);
|
||||
if (!withdrawal) {
|
||||
async processWithdrawal(withdrawalId: string, processedBy: string = 'system'): Promise<WithdrawalRequest> {
|
||||
const repo = await withdrawalRepository.findById(withdrawalId);
|
||||
if (!repo) {
|
||||
throw new Error(`Withdrawal not found: ${withdrawalId}`);
|
||||
}
|
||||
|
||||
if (withdrawal.status !== 'pending') {
|
||||
if (repo.status !== 'pending') {
|
||||
throw new Error('Withdrawal is not pending');
|
||||
}
|
||||
|
||||
withdrawal.status = 'processing';
|
||||
withdrawal.processedAt = new Date();
|
||||
const updated = await withdrawalRepository.process(withdrawalId, processedBy);
|
||||
if (!updated) {
|
||||
throw new Error('Failed to process withdrawal');
|
||||
}
|
||||
|
||||
return withdrawal;
|
||||
return mapRepoWithdrawalToService(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a withdrawal (admin)
|
||||
*/
|
||||
async completeWithdrawal(withdrawalId: string): Promise<WithdrawalRequest> {
|
||||
const withdrawal = withdrawalRequests.get(withdrawalId);
|
||||
if (!withdrawal) {
|
||||
const repo = await withdrawalRepository.findById(withdrawalId);
|
||||
if (!repo) {
|
||||
throw new Error(`Withdrawal not found: ${withdrawalId}`);
|
||||
}
|
||||
|
||||
if (withdrawal.status !== 'processing') {
|
||||
if (repo.status !== 'processing') {
|
||||
throw new Error('Withdrawal is not being processed');
|
||||
}
|
||||
|
||||
// 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
|
||||
withdrawal.status = 'completed';
|
||||
withdrawal.completedAt = new Date();
|
||||
// Complete withdrawal in database
|
||||
const completed = await withdrawalRepository.complete(withdrawalId);
|
||||
if (!completed) {
|
||||
throw new Error('Failed to complete withdrawal');
|
||||
}
|
||||
|
||||
// Find and update the pending transaction in database
|
||||
const { transactions: pendingTxs } = await transactionRepository.findByAccountId(
|
||||
withdrawal.accountId,
|
||||
repo.accountId,
|
||||
{ transactionType: 'withdrawal', status: 'pending' }
|
||||
);
|
||||
|
||||
const pendingTx = pendingTxs.find((t) => t.amount === withdrawal.amount);
|
||||
const pendingTx = pendingTxs.find((t) => t.amount === repo.amount);
|
||||
if (pendingTx) {
|
||||
await transactionRepository.updateStatus(pendingTx.id, 'completed', {
|
||||
balanceAfter: updatedAccount.balance,
|
||||
});
|
||||
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(
|
||||
withdrawalId: string,
|
||||
reason: string
|
||||
reason: string,
|
||||
processedBy: string = 'system'
|
||||
): Promise<WithdrawalRequest> {
|
||||
const withdrawal = withdrawalRequests.get(withdrawalId);
|
||||
if (!withdrawal) {
|
||||
const repo = await withdrawalRepository.findById(withdrawalId);
|
||||
if (!repo) {
|
||||
throw new Error(`Withdrawal not found: ${withdrawalId}`);
|
||||
}
|
||||
|
||||
if (withdrawal.status === 'completed') {
|
||||
if (repo.status === 'completed') {
|
||||
throw new Error('Cannot reject completed withdrawal');
|
||||
}
|
||||
|
||||
withdrawal.status = 'rejected';
|
||||
withdrawal.rejectionReason = reason;
|
||||
withdrawal.processedAt = new Date();
|
||||
// Reject withdrawal in database
|
||||
const rejected = await withdrawalRepository.reject(withdrawalId, reason, processedBy);
|
||||
if (!rejected) {
|
||||
throw new Error('Failed to reject withdrawal');
|
||||
}
|
||||
|
||||
// Find and cancel the pending transaction in database
|
||||
const { transactions: pendingTxs } = await transactionRepository.findByAccountId(
|
||||
withdrawal.accountId,
|
||||
repo.accountId,
|
||||
{ transactionType: 'withdrawal', status: 'pending' }
|
||||
);
|
||||
|
||||
const pendingTx = pendingTxs.find((t) => t.amount === withdrawal.amount);
|
||||
const pendingTx = pendingTxs.find((t) => t.amount === repo.amount);
|
||||
if (pendingTx) {
|
||||
await transactionRepository.updateStatus(pendingTx.id, 'cancelled', {
|
||||
failureReason: reason,
|
||||
@ -484,7 +519,7 @@ class TransactionService {
|
||||
});
|
||||
}
|
||||
|
||||
return withdrawal;
|
||||
return mapRepoWithdrawalToService(rejected);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
@ -495,9 +530,11 @@ class TransactionService {
|
||||
* Get distributions for an account
|
||||
*/
|
||||
async getAccountDistributions(accountId: string): Promise<Distribution[]> {
|
||||
return Array.from(distributions.values())
|
||||
.filter((d) => d.accountId === accountId)
|
||||
.sort((a, b) => b.periodEnd.getTime() - a.periodEnd.getTime());
|
||||
const account = await accountService.getAccountById(accountId);
|
||||
const userId = account?.userId || '';
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
const performanceFee = grossEarnings * (performanceFeePercent / 100);
|
||||
const netEarnings = grossEarnings - performanceFee;
|
||||
// Platform takes performance fee, client gets the rest
|
||||
const platformSharePercent = performanceFeePercent;
|
||||
const clientSharePercent = 100 - performanceFeePercent;
|
||||
|
||||
const distribution: Distribution = {
|
||||
id: uuidv4(),
|
||||
const repo = await distributionRepository.create({
|
||||
accountId,
|
||||
userId: account.userId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
grossEarnings,
|
||||
performanceFee,
|
||||
netEarnings,
|
||||
status: 'pending',
|
||||
distributedAt: null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
grossProfit: grossEarnings,
|
||||
managementFee: 0,
|
||||
platformSharePercent,
|
||||
clientSharePercent,
|
||||
});
|
||||
|
||||
distributions.set(distribution.id, distribution);
|
||||
return distribution;
|
||||
return mapRepoDistributionToService(repo, account.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute earnings
|
||||
*/
|
||||
async distributeEarnings(distributionId: string): Promise<Distribution> {
|
||||
const distribution = distributions.get(distributionId);
|
||||
if (!distribution) {
|
||||
const repo = await distributionRepository.findById(distributionId);
|
||||
if (!repo) {
|
||||
throw new Error(`Distribution not found: ${distributionId}`);
|
||||
}
|
||||
|
||||
if (distribution.status === 'distributed') {
|
||||
if (repo.status === 'distributed') {
|
||||
throw new Error('Distribution already completed');
|
||||
}
|
||||
|
||||
// Get account balance before earnings
|
||||
const accountBefore = await accountService.getAccountById(distribution.accountId);
|
||||
const balanceBefore = accountBefore?.balance || 0;
|
||||
const account = await accountService.getAccountById(repo.accountId);
|
||||
if (!account) {
|
||||
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(
|
||||
distribution.accountId,
|
||||
distribution.grossEarnings,
|
||||
distribution.performanceFee
|
||||
repo.accountId,
|
||||
repo.grossProfit,
|
||||
repo.platformAmount
|
||||
);
|
||||
|
||||
// Get updated account
|
||||
const account = await accountService.getAccountById(distribution.accountId);
|
||||
const updatedAccount = await accountService.getAccountById(repo.accountId);
|
||||
|
||||
// Create distribution transaction in database (net earnings)
|
||||
if (distribution.netEarnings !== 0) {
|
||||
// Create distribution transaction in database (client earnings)
|
||||
if (repo.clientAmount !== 0) {
|
||||
const earningTx = await transactionRepository.create({
|
||||
accountId: distribution.accountId,
|
||||
accountId: repo.accountId,
|
||||
transactionType: 'distribution',
|
||||
amount: distribution.netEarnings,
|
||||
amount: repo.clientAmount,
|
||||
balanceBefore,
|
||||
balanceAfter: account?.balance,
|
||||
balanceAfter: updatedAccount?.balance,
|
||||
distributionId,
|
||||
notes: `Earnings for ${distribution.periodStart.toLocaleDateString()} - ${distribution.periodEnd.toLocaleDateString()}`,
|
||||
notes: `Earnings for ${repo.periodStart.toLocaleDateString()} - ${repo.periodEnd.toLocaleDateString()}`,
|
||||
});
|
||||
// Mark as completed immediately
|
||||
await transactionRepository.updateStatus(earningTx.id, 'completed');
|
||||
}
|
||||
|
||||
distribution.status = 'distributed';
|
||||
distribution.distributedAt = new Date();
|
||||
// Mark distribution as distributed
|
||||
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
|
||||
*/
|
||||
async getPendingDistributions(): Promise<Distribution[]> {
|
||||
return Array.from(distributions.values())
|
||||
.filter((d) => d.status === 'pending')
|
||||
.sort((a, b) => a.periodEnd.getTime() - b.periodEnd.getTime());
|
||||
const pendingDistributions = await distributionRepository.findPending();
|
||||
|
||||
// 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