CycleInvestment Model Analysis
Executive Summary
With the introduction of comprehensive double-entry accounting via journal entries, the CycleInvestment model has become largely redundant. Most of its original purposes are now better served by the journal entry system.
Current State Analysis
What CycleInvestment Currently Stores
model CycleInvestment {
id Int // Primary key
cycleId Int // Which cycle
investorId Int // Which user
roleAtInvestment UserRole // Role snapshot
amount Decimal // Investment amount
sharePercentage Decimal // Their % of cycle
payoutStrategyId Int? // Payout preference
investedAt DateTime // When they joined
}
Original Design Intentions
- Snapshot Model: Capture point-in-time participation when user opts into cycle
- Share Calculation: Track user's percentage of total cycle capital
- Payout Strategy: Link to user's profit distribution preference
- Role Tracking: Record user's role at time of investment (for profit distribution)
- Investment History: Show user's participation across cycles
Redundancy with Journal Entry System
What Journal Entries Now Provide
| CycleInvestment Field | Journal Entry Equivalent | Status |
|---|---|---|
amount | Account 2130 (User Capital Deployed) balance by userId + cycleId | REDUNDANT |
roleAtInvestment | ProfitDistribution.roleAtDistribution | PARTIALLY REDUNDANT |
sharePercentage | Can be calculated from capital deployed | REDUNDANT |
investedAt | Journal entry entryDate where capital moved to 2130 | REDUNDANT |
What's NOT Redundant
-
payoutStrategyId- User's profit distribution preference (compound vs payout)- Not tracked in journal entries
- Needed for profit distribution decision
-
Participation Signal - Easy query: "Who is in this cycle?"
- Could query journal entries but slower
- Convenience for UI display
Problems with Current Implementation
1. Data Inconsistency Risk
Problem: Two sources of truth for the same data
- CycleInvestment.amount says one thing
- Journal entries (account 2130) say another
Example: Your Cycle #1 case
- You have ProfitDistribution for Cycle #1 (you participated)
- You have NO CycleInvestment for Cycle #1 (missing record)
- Journal entries show your deposits and distributions
- Dashboard fails because it relies on CycleInvestment
2. Manual Synchronisation Required
When users participate in cycles:
// Current flow creates TWO records:
1. Create CycleInvestment record (participate API)
2. Create journal entries moving capital (participate API)
// Risk: One succeeds, other fails = inconsistency
3. Historical Data Gaps
- Legacy investments may have journal entries but no CycleInvestment
- Migrations or corrections can miss creating CycleInvestment records
- Results in incomplete user history displays
Current Usage Analysis
Where CycleInvestment is REQUIRED
-
Payout Strategy Selection (
/api/investments/[id]/payout-strategy)- Links user's strategy preference to specific cycle participation
- NEEDED: No alternative in journal system
-
Dashboard "My Investments" Tab (
/app/dashboard/page.tsx)- Shows user's cycle participations with payout strategies
- Could be replaced with ProfitDistribution + journal query
- CONVENIENCE: Makes queries simpler
-
Active Cycles Count (
/api/user/balance-stats)- Counts cycles where user has deployed capital
- Could query journal entries with status filter
- CONVENIENCE: Faster query
Where CycleInvestment is REDUNDANT
- Share Percentage Calculation (computed from capital deployed)
- Investment Amount Tracking (account 2130 journal entries)
- Investment Timing (journal entry dates)
- Role Tracking (ProfitDistribution has this)
Design Recommendations
Option A: Deprecate CycleInvestment (Recommended)
Approach: Remove CycleInvestment, use journal entries as source of truth
Changes Needed:
-
Move Payout Strategy to User Level
model User {
defaultPayoutStrategyId Int?
payoutStrategies PayoutStrategy[]
}
model PayoutStrategy {
// Keep as is, but reference user globally
// User can override per-opportunity if needed
} -
Replace Dashboard Queries
// OLD: Find CycleInvestments
const investments = await prisma.cycleInvestment.findMany({
where: { investorId: userId }
})
// NEW: Find ProfitDistributions (proof of participation)
const investments = await prisma.profitDistribution.findMany({
where: { userId: userId }
})
// OR query journal entries for 2130 movements -
Calculate Active Cycles from Journal Entries
// Count distinct cycles with deployed capital (2130 balance > 0)
const activeCycles = await prisma.journalEntryLine.groupBy({
by: ['cycleId'],
where: {
userId: userId,
accountCode: '2130',
journalEntry: { status: 'posted' }
},
having: {
balance: { gt: 0 }
}
})
Pros:
- Single source of truth (journal entries)
- No synchronisation overhead
- Consistent with accounting best practices
- Automatically handles all scenarios (legacy, corrections, etc.)
Cons:
- More complex queries
- Need to migrate existing payout strategy associations
- UI changes required
Option B: Keep CycleInvestment as Snapshot Cache (Current)
Approach: Keep CycleInvestment but treat as denormalised cache
Changes Needed:
-
Make it Clear it's Derived Data
model CycleInvestment {
// ... existing fields ...
@@index([cycleId, investorId])
@@map("cycle_investments")
// Add comment: "Denormalised cache - journal entries are source of truth"
} -
Add Reconciliation
- Background job to sync CycleInvestment from journal entries
- Detect and fix missing records (like your Cycle #1)
-
Add Safeguards
- Validation that CycleInvestment.amount matches journal balance
- Alerts when they diverge
Pros:
- Simpler queries (for now)
- Minimal code changes
- Keeps current UI working
Cons:
- Ongoing synchronisation complexity
- Risk of data inconsistencies
- Technical debt accumulation
- Still need to fix missing historical records
Option C: Hybrid - Minimal CycleInvestment
Approach: Reduce CycleInvestment to only what's truly needed
New Schema:
model CycleParticipation {
id Int @id @default(autoincrement())
cycleId Int
userId Int
payoutStrategyId Int? // Only field we really need
createdAt DateTime @default(now())
cycle Cycle
user User
payoutStrategy PayoutStrategy?
@@unique([cycleId, userId])
@@map("cycle_participations")
}
Remove: amount, sharePercentage, roleAtInvestment, investedAt (all derivable)
Pros:
- Keeps essential functionality (payout strategy)
- Reduces redundancy
- Cleaner data model
Cons:
- Requires migration
- Still need to query journal entries for amounts/shares
Migration Path (If Option A Chosen)
Phase 1: Create Missing Records
-- Find users with ProfitDistributions but no CycleInvestment
INSERT INTO cycle_investments (cycle_id, investor_id, role_at_investment, amount, share_percentage, invested_at)
SELECT
pd.cycle_id,
pd.user_id,
pd.role_at_distribution,
pd.investment_amount,
-- Calculate share from investment_amount / cycle total
pd.investment_amount / c.starting_capital,
c.start_date
FROM profit_distributions pd
JOIN cycles c ON c.id = pd.cycle_id
LEFT JOIN cycle_investments ci ON ci.cycle_id = pd.cycle_id AND ci.investor_id = pd.user_id
WHERE ci.id IS NULL;
Phase 2: Add User-Level Payout Strategies
ALTER TABLE users ADD COLUMN default_payout_strategy_id INT REFERENCES payout_strategies(id);
-- Migrate existing strategy preferences
UPDATE users u
SET default_payout_strategy_id = (
SELECT payout_strategy_id
FROM cycle_investments ci
WHERE ci.investor_id = u.id
AND payout_strategy_id IS NOT NULL
LIMIT 1
);
Phase 3: Update Code to Use Journal Entries
- Update
/api/investmentsto query ProfitDistribution - Update dashboard to derive data from journal entries
- Add convenience functions in lib/accounting.ts
Phase 4: Deprecation
- Mark CycleInvestment as deprecated in schema comments
- Stop creating new records
- Keep table for historical reference
Recommendation
Adopt Option A: Deprecate CycleInvestment
Rationale:
- Journal entries are already the source of truth for all financial data
- Maintaining dual systems creates ongoing complexity and risk
- Your Cycle #1 issue demonstrates the fragility of the current approach
- Aligns with double-entry accounting best practices
- Simplifies future development (one less table to think about)
Immediate Action:
- Fix your Cycle #1 missing CycleInvestment record
- Create reconciliation script to find other gaps
- Plan migration timeline for Option A
Timeline:
- Short term (this week): Fix immediate gaps, add reconciliation
- Medium term (this month): Implement Option C (Hybrid)
- Long term (next quarter): Complete Option A migration
Appendix: Code Examples
Current Participation Flow
// /api/cycles/[id]/participate/route.ts
// Creates BOTH CycleInvestment AND journal entries
Proposed Journal-Only Flow
// No CycleInvestment creation needed
// Just journal entries:
// DR: 2120 User Capital Available
// CR: 2130 User Capital Deployed