12 KiB
Research: Single Family Office Platform Transformation
Phase 0 Output | Date: 2026-03-15
1. Prisma Schema Extension Strategy
Decision: Add 9 new models and 8 new enums to the existing prisma/schema.prisma, connecting to the existing User and Account models via foreign keys. No modifications to existing models except adding optional back-references.
Rationale: The existing schema is stable (101 migrations over 4 years). New models are additive — they reference existing User and Account models without modifying their structure. The Entity model connects to User (who administers it) and Account (what it owns). This preserves backward compatibility: all existing Ghostfolio functionality continues to work for users who don't use family office features.
Alternatives considered:
- Separate database: Rejected — would require cross-database joins for valuations that reference existing market data, and duplicated exchange rate logic.
- JSON fields on existing models: Rejected — partnership membership percentages, K-1 box data, and valuation histories need proper relational querying, aggregation, and referential integrity.
- New Prisma schema file: Rejected — Prisma does not support multi-schema in a single datasource cleanly. One schema file is the established pattern.
2. Entity-Account Ownership Model
Decision: Create a new Ownership model that links Entity to Account (using the existing composite key [id, userId]). Ownership records include a percentage (Decimal), effective date, and optional end date. The existing Account model gains an optional ownerships Ownership[] back-reference but its structure is otherwise unchanged.
Rationale: The existing Account model uses a composite PK of (id, userId). The Ownership model references both accountId and accountUserId to maintain this relationship. Users who don't create entities still use accounts normally — the ownerships relation is simply empty. Percentages are stored as Decimal (not Float) for financial precision, consistent with industry standards.
Alternatives considered:
- Add
entityIddirectly to Account: Rejected — an account can be owned by multiple entities with different percentages (e.g., Trust A: 60%, LLC B: 40%). - Use the existing
Accessmodel for ownership: Rejected —Accessis a user-to-user permission grant, not an entity-to-asset ownership with percentages.
3. Partnership Valuation & NAV Tracking
Decision: Use a dedicated PartnershipValuation model (partnership ID + date + NAV + source) rather than reusing the existing MarketData model. Partnership assets get a separate AssetValuation model.
Rationale: The existing MarketData model is designed for public market data (dataSource + symbol + date, populated by data providers). Partnership NAVs are manually entered, have different sources (appraisal, fund admin statement), and don't map to a data source/symbol pair. A separate model avoids polluting market data with manual entries and allows partnership-specific metadata (valuation source, fund admin notes).
Alternatives considered:
- Reuse
MarketDatawithMANUALdataSource: Rejected —MarketDatauses a unique constraint on(dataSource, date, symbol)and is widely queried by the data gathering system. Inserting partnership NAVs would create phantom symbols in market data listings and break data-gathering cron jobs. - Store NAV as JSON on Partnership: Rejected — time-series data needs efficient date-range queries and ordering.
4. K-1 Data Structure
Decision: Store K-1 box data as a structured Json field on the KDocument model, with a well-defined TypeScript interface K1Data enforced at the application layer. Use Prisma's Json type (maps to PostgreSQL jsonb).
Rationale: K-1 forms have ~20 standard boxes, but the IRS occasionally adds/modifies boxes. A JSON field provides flexibility for future box additions without schema migrations, while the TypeScript interface ensures type safety at the application layer. The existing codebase already uses this pattern extensively — SymbolProfile.countries, SymbolProfile.sectors, SymbolProfile.holdings, and Settings.settings are all Json fields with TypeScript interfaces.
Alternatives considered:
- Separate columns per K-1 box: Rejected — 20+ nullable Decimal columns would be unwieldy, and each IRS form revision would require a migration.
- Normalized K-1 line items table: Rejected — over-engineering for manual data entry of ~20 fields per form. JSON with application-layer validation is simpler and follows existing patterns.
5. Private Market Performance Calculations
Decision: Implement XIRR (Newton-Raphson), TVPI, DPI, RVPI, and Modified Dietz as a standalone FamilyOfficePerformanceCalculator class in apps/api/src/app/portfolio/calculator/family-office/. This is NOT a subclass of the existing PortfolioCalculator abstract class.
Rationale: The existing PortfolioCalculator hierarchy (base → MWR/ROAI/ROI/TWR subclasses) is designed for public market securities with daily market prices, specific buy/sell activities, and exchange-rate-adjusted time-series. It uses CurrentRateService to fetch daily prices and builds transaction-point timelines. Private market partnerships have fundamentally different inputs: periodic NAV statements (not daily prices), capital calls/distributions (not buy/sell orders), and no active market data feed. Forcing partnerships into the existing calculator would require extensive refactoring of the base class and risk breaking the 21 existing ROAI spec tests.
Alternatives considered:
- Extend existing
PortfolioCalculatorbase class: Rejected — the base class'scomputeSnapshot()method is tightly coupled toCurrentRateServicefor daily market prices andPortfolioOrderfor buy/sell activities. These don't map to partnership cash flows. - New subclass of
PortfolioCalculator: Rejected — the abstract methodscalculateOverallPerformance()andgetSymbolMetrics()expectTimelinePosition[]with daily data points. Partnership valuations are periodic (quarterly/monthly), not daily.
6. Benchmark Comparison Overlay
Decision: Create a FamilyOfficeBenchmarkService that extends the existing BenchmarkService capabilities by adding period-matched comparison. It uses the existing DataProviderService.getHistoricalData() for benchmark price series and computes returns over the same periods as partnership performance.
Rationale: The existing BenchmarkService already stores benchmark symbol profiles in the Property table and fetches quotes via DataProviderService. The new service reuses this infrastructure but adds: (1) period-aligned return calculation, (2) excess return computation, (3) cumulative return comparison data for charting. Pre-configured benchmarks (SPY for S&P 500, AGG for bonds, VNQ for real estate, TIP for CPI proxy) use Yahoo Finance data already available through the existing YAHOO data source.
Alternatives considered:
- Modify existing
BenchmarkService: Rejected — the existing service is focused on trend analysis (50d/200d moving averages) and market condition classification. Adding period-matched comparison would bloat it. Better to compose than modify. - External benchmark data API: Rejected — Yahoo Finance already provides historical data for all needed benchmarks via the existing
yahoo-finance2integration.
7. File Upload for K-1 PDF Documents
Decision: Implement a minimal file upload module using NestJS built-in @nestjs/platform-express (Multer) integration. Files stored on local filesystem under a configurable UPLOAD_DIR path, with metadata (file path, type, associations) stored in the Document Prisma model. Add @nestjs/platform-express Multer types (already a dependency via NestJS).
Rationale: The existing codebase has no file upload handling. Multer is included with @nestjs/platform-express (already a dependency at v11.0.9). Local filesystem storage is the simplest approach for a single family office deployment. The Document model stores the path and metadata, allowing future migration to cloud storage by swapping only the storage backend.
Alternatives considered:
- Cloud storage (S3, Azure Blob): Rejected for initial scope — adds deployment complexity and external dependency not needed for single-office deployment. Can be added later by implementing a storage interface.
- Store PDFs as Base64 in database: Rejected — PostgreSQL is not designed for large binary objects; filesystem is more appropriate and allows direct file serving.
8. Exchange Rate Handling for Multi-Currency Partnerships
Decision: Reuse the existing ExchangeRateDataService for all currency conversions. When a partnership's NAV is in a different currency than the entity's base currency, apply the exchange rate at the valuation date using ExchangeRateDataService.toCurrencyAtDate().
Rationale: The existing service already handles historical exchange rates with gap-filling (walks backward to last known rate), supports all major currencies, and is used extensively by the portfolio calculator and account service. No new currency logic needed.
Alternatives considered: None — the existing service is comprehensive and well-tested.
9. Angular Page Architecture
Decision: New family office pages follow the existing standalone component pattern with lazy-loaded routes. New pages are added as siblings to existing routes (not nested under /portfolio). Route structure: /entities, /partnerships, /distributions, /k-documents, /reports, /family-dashboard.
Rationale: The existing /portfolio route is the personal portfolio view and should remain unchanged. Family office features represent a distinct domain that warrants top-level navigation. Using separate routes keeps the existing portfolio experience intact while providing dedicated family office views. Lazy loading ensures no impact on initial bundle size for users who don't navigate to FO pages.
Alternatives considered:
- Nest under
/portfolio/family-office/: Rejected — creates deep nesting and conflates personal portfolio tracking with institutional family office management. - Replace existing portfolio pages: Rejected — the existing portfolio tracking for public securities is still valuable within the family office context. Family members may want both the traditional portfolio view (for their liquid holdings) and the family office view (for the full picture).
10. Permission Model for Family Office Features
Decision: Add new permission strings to the existing permissions.ts in libs/common. Family office features require the same ADMIN or USER role — no new role types needed. Permissions: createEntity, updateEntity, deleteEntity, createPartnership, updatePartnership, deletePartnership, createDistribution, deleteDistribution, createKDocument, updateKDocument, uploadDocument.
Rationale: The existing permission system is string-based and checked via HasPermissionGuard + @HasPermission() decorator. Adding new permission strings follows the exact same pattern used by account, order, and admin operations. All family office data is scoped to the authenticated user via userId on the Entity model.
Alternatives considered:
- Entity-level permissions (different users see different entities): Rejected for initial scope — this is a single family office with 1-5 admin users who all see everything. Can be added later using the existing
Accessmodel pattern.