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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,16 @@
/**
* Investment Product Service
* 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;

View File

@ -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();
}
}