Browse Source

feat: portfolio performance views and family office improvements (WIP)

pull/6603/head
Robert Patch 2 weeks ago
parent
commit
e8f5af7a30
  1. 40
      .dockerignore
  2. 3
      .gitattributes
  3. 5
      .github/agents/copilot-instructions.md
  4. 1
      .gitignore
  5. 11
      Dockerfile
  6. 114
      RAILWAY.md
  7. 18
      apps/api/src/app/admin/admin.controller.ts
  8. 3
      apps/api/src/app/admin/admin.module.ts
  9. 1942
      apps/api/src/app/admin/dev-seed.service.ts
  10. 56
      apps/api/src/app/family-office/family-office.controller.ts
  11. 724
      apps/api/src/app/family-office/family-office.service.ts
  12. 7
      apps/client/src/app/app.routes.ts
  13. 52
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  14. 18
      apps/client/src/app/components/admin-overview/admin-overview.html
  15. 21
      apps/client/src/app/components/header/header.component.html
  16. 591
      apps/client/src/app/pages/portfolio-views/portfolio-views-page.component.ts
  17. 692
      apps/client/src/app/pages/portfolio-views/portfolio-views-page.html
  18. 14
      apps/client/src/app/pages/portfolio-views/portfolio-views-page.routes.ts
  19. 401
      apps/client/src/app/pages/portfolio-views/portfolio-views-page.scss
  20. 86
      apps/client/src/app/services/family-office-data.service.ts
  21. 78
      docker/docker-compose.railway.yml
  22. 13
      docker/entrypoint.sh
  23. 24
      libs/common/src/lib/helper.ts
  24. 87
      libs/common/src/lib/interfaces/family-office.interface.ts
  25. 16
      libs/common/src/lib/interfaces/index.ts
  26. 8
      libs/common/src/lib/interfaces/k-document.interface.ts
  27. 35
      libs/common/src/lib/pipes/accounting-number.pipe.ts
  28. 3
      libs/common/src/lib/pipes/index.ts
  29. 15
      libs/ui/src/lib/services/admin.service.ts
  30. 309
      prisma/migrations/20260316120000_added_family_office_tables/migration.sql
  31. 11
      railway.toml
  32. 36
      specs/003-portfolio-performance-views/checklists/requirements.md
  33. 210
      specs/003-portfolio-performance-views/contracts/api-contracts.md
  34. 229
      specs/003-portfolio-performance-views/data-model.md
  35. 79
      specs/003-portfolio-performance-views/plan.md
  36. 152
      specs/003-portfolio-performance-views/quickstart.md
  37. 129
      specs/003-portfolio-performance-views/research.md
  38. 145
      specs/003-portfolio-performance-views/spec.md
  39. 215
      specs/003-portfolio-performance-views/tasks.md

40
.dockerignore

@ -0,0 +1,40 @@
node_modules
.nx
.angular
dist
out-tsc
tmp
coverage
.git
.github
.vscode
.cursor
.idea
.env
.env.dev
.env.prod
.env.local
*.md
!CHANGELOG.md
!README.md
docker
!docker/entrypoint.sh
specs
test
uploads
*.log
npm-debug.log*
Thumbs.db
.DS_Store
.storybook
*.sublime-workspace
*.launch
.settings
.classpath
.project
.c9
libpeerconnection.log
testem.log
.sass-cache
connect.lock
typings

3
.gitattributes

@ -0,0 +1,3 @@
# Ensure shell scripts always have Unix line endings
*.sh text eol=lf
docker/entrypoint.sh text eol=lf

5
.github/agents/copilot-instructions.md

@ -1,8 +1,10 @@
# portfolio-management Development Guidelines
Auto-generated from all feature plans. Last updated: 2026-03-15
Auto-generated from all feature plans. Last updated: 2026-03-16
## Active Technologies
- TypeScript 5.9.2, Node.js >= 22.18.0 + Angular 21.1.1, NestJS 11.1.14, Angular Material 21.1.1, Prisma 6.19.0, big.js, date-fns 4.1.0 (003-portfolio-performance-views)
- PostgreSQL via Prisma ORM (003-portfolio-performance-views)
- TypeScript 5.9.2, Node.js ≥22.18.0 + NestJS 11.1.14 (API), Angular 21.1.1 + Angular Material 21.1.1 (client), Prisma 6.19.0 (ORM), Nx 22.5.3 (monorepo), big.js (decimal math), date-fns 4.1.0, chart.js 4.5.1, Bull 4.16.5 (job queues), Redis (caching), yahoo-finance2 3.13.2 (001-family-office-transform)
@ -23,6 +25,7 @@ npm test; npm run lint
TypeScript 5.9.2, Node.js ≥22.18.0: Follow standard conventions
## Recent Changes
- 003-portfolio-performance-views: Added TypeScript 5.9.2, Node.js >= 22.18.0 + Angular 21.1.1, NestJS 11.1.14, Angular Material 21.1.1, Prisma 6.19.0, big.js, date-fns 4.1.0
- 001-family-office-transform: Added TypeScript 5.9.2, Node.js ≥22.18.0 + NestJS 11.1.14 (API), Angular 21.1.1 + Angular Material 21.1.1 (client), Prisma 6.19.0 (ORM), Nx 22.5.3 (monorepo), big.js (decimal math), date-fns 4.1.0, chart.js 4.5.1, Bull 4.16.5 (job queues), Redis (caching), yahoo-finance2 3.13.2

1
.gitignore

@ -28,6 +28,7 @@ npm-debug.log
.cursor/rules/nx-rules.mdc
.env
.env.prod
.env.railway
.github/instructions/nx.instructions.md
.nx/cache
.nx/workspace-data

11
Dockerfile

@ -60,7 +60,16 @@ RUN apt-get update && apt-get install -y --no-install-suggests \
COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps/
COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/
RUN sed -i 's/\r$//' /ghostfolio/entrypoint.sh && chmod +x /ghostfolio/entrypoint.sh
WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333}
# Railway dynamically assigns PORT; default to 3333 for local usage
ENV PORT=3333
EXPOSE ${PORT}
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD curl -f http://localhost:${PORT}/api/v1/health || exit 1
USER node
CMD [ "/ghostfolio/entrypoint.sh" ]

114
RAILWAY.md

@ -0,0 +1,114 @@
# Railway Deployment Guide
This guide explains how to deploy the application to [Railway](https://railway.app).
## Architecture
The app deploys as a **single Docker image** that includes both the NestJS API and the pre-built Angular client (served via `@nestjs/serve-static`). On Railway, you provision **three services**:
| Service | Type | Notes |
|---------|------|-------|
| **App** | Docker (this repo) | API + Client in one container |
| **Postgres** | Railway managed | `postgresql://...` connection string |
| **Redis** | Railway managed | Used for caching and Bull job queues |
## Quick Start
### 1. Create a Railway Project
1. Go to [railway.app](https://railway.app) and create a new project
2. Add a **PostgreSQL** service (click "Add Service" → "Database" → "PostgreSQL")
3. Add a **Redis** service (click "Add Service" → "Database" → "Redis")
4. Add your app (click "Add Service" → "GitHub Repo" → select this repo)
### 2. Configure Environment Variables
In your app service's **Variables** tab, set:
```
# From Railway's Postgres service (use the "Connect" tab to copy these)
DATABASE_URL=postgresql://USER:PASSWORD@HOST:PORT/DB?connect_timeout=300&sslmode=prefer
POSTGRES_DB=railway
POSTGRES_USER=postgres
POSTGRES_PASSWORD=<from Railway>
# From Railway's Redis service (use the "Connect" tab to copy these)
REDIS_HOST=<Redis private host>
REDIS_PORT=6379
REDIS_PASSWORD=<from Railway>
# App secrets (generate random strings)
ACCESS_TOKEN_SALT=<random-32-char-string>
JWT_SECRET_KEY=<random-32-char-string>
# Railway sets PORT automatically — do NOT set it manually
```
> **Tip:** Use Railway's [variable references](https://docs.railway.app/guides/variables#referencing-another-services-variable) to auto-wire `DATABASE_URL` from the Postgres service:
> ```
> DATABASE_URL=${{Postgres.DATABASE_URL}}?connect_timeout=300&sslmode=prefer
> REDIS_HOST=${{Redis.REDIS_HOST}}
> REDIS_PORT=${{Redis.REDIS_PORT}}
> REDIS_PASSWORD=${{Redis.REDIS_PASSWORD}}
> ```
### 3. Deploy
Railway auto-detects the `railway.toml` and `Dockerfile` in the repo root. Push to your connected branch and Railway will:
1. Build the Docker image (multi-stage: builder → slim runtime)
2. Run database migrations via `prisma migrate deploy`
3. Seed the database (if applicable)
4. Start the Node.js server on the Railway-assigned `$PORT`
5. Health-check at `/api/v1/health`
### 4. Set Up Networking
- In the app service's **Settings** tab, click **Generate Domain** to get a public URL
- Optionally add a custom domain
## Local Testing (Railway-like)
To test the full Docker build locally in a Railway-equivalent topology:
```bash
# 1. Copy the template and fill in your values
cp .env.railway .env.railway.local
# 2. Build and run all services
docker compose -f docker/docker-compose.railway.yml --env-file .env.railway.local up --build
# 3. Open http://localhost:3333
```
## File Overview
| File | Purpose |
|------|---------|
| `Dockerfile` | Multi-stage build: installs deps, builds API + Client, creates slim runtime image |
| `railway.toml` | Railway build & deploy configuration |
| `.dockerignore` | Excludes unnecessary files from Docker context |
| `.env.railway` | Template for Railway environment variables |
| `docker/entrypoint.sh` | Container startup: runs migrations, seeds DB, starts server |
| `docker/docker-compose.railway.yml` | Local simulation of Railway topology |
## Troubleshooting
### Build fails with OOM
Railway's free tier has limited memory. If the build fails, try:
- Upgrade to a paid plan (recommended for production)
- Or set `NODE_OPTIONS=--max-old-space-size=4096` in build environment variables
### Database connection refused
- Ensure `DATABASE_URL` uses the **private** networking hostname (not external)
- Verify the Postgres service is running in the same Railway project
- Check `?connect_timeout=300&sslmode=prefer` is appended to the URL
### Redis connection issues
- Use `REDIS_HOST` from the Redis service's private networking
- Ensure `REDIS_PASSWORD` matches exactly
### Health check failing
- The app needs ~30-40 seconds to start (migrations + seed + boot)
- The health check has a 40-second start period configured
- Check logs in Railway dashboard for startup errors

18
apps/api/src/app/admin/admin.controller.ts

@ -55,6 +55,7 @@ import { isDate, parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service';
import { DevSeedService } from './dev-seed.service';
@Controller('admin')
export class AdminController {
@ -63,6 +64,7 @@ export class AdminController {
private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService,
private readonly demoService: DemoService,
private readonly devSeedService: DevSeedService,
private readonly manualService: ManualService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@ -334,4 +336,20 @@ export class AdminController {
public async getUser(@Param('id') id: string): Promise<AdminUserResponse> {
return this.adminService.getUser(id);
}
@Delete('family-office-data')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async clearFamilyOfficeData() {
return this.devSeedService.clearDatabase();
}
@Post('family-office-data/seed')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async seedFamilyOfficeData() {
return this.devSeedService.populateDummyData({
userId: this.request.user.id
});
}
}

3
apps/api/src/app/admin/admin.module.ts

@ -16,6 +16,7 @@ import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { DevSeedService } from './dev-seed.service';
import { QueueModule } from './queue/queue.module';
@Module({
@ -36,7 +37,7 @@ import { QueueModule } from './queue/queue.module';
TransformDataSourceInRequestModule
],
controllers: [AdminController],
providers: [AdminService],
providers: [AdminService, DevSeedService],
exports: [AdminService]
})
export class AdminModule {}

1942
apps/api/src/app/admin/dev-seed.service.ts

File diff suppressed because it is too large

56
apps/api/src/app/family-office/family-office.controller.ts

@ -25,6 +25,62 @@ export class FamilyOfficeController {
});
}
@Get('portfolio-summary')
@HasPermission(permissions.readEntity)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPortfolioSummary(
@Query('quarter') quarter?: string,
@Query('valuationYear') valuationYear?: string
) {
const year = valuationYear
? parseInt(valuationYear, 10)
: new Date().getFullYear();
return this.familyOfficeService.getPortfolioSummary({
quarter: quarter ? parseInt(quarter, 10) : undefined,
userId: this.request.user.id,
valuationYear: year
});
}
@Get('asset-class-summary')
@HasPermission(permissions.readEntity)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAssetClassSummary(
@Query('quarter') quarter?: string,
@Query('valuationYear') valuationYear?: string
) {
const year = valuationYear
? parseInt(valuationYear, 10)
: new Date().getFullYear();
return this.familyOfficeService.getAssetClassSummary({
quarter: quarter ? parseInt(quarter, 10) : undefined,
userId: this.request.user.id,
valuationYear: year
});
}
@Get('activity')
@HasPermission(permissions.readEntity)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getActivity(
@Query('entityId') entityId?: string,
@Query('partnershipId') partnershipId?: string,
@Query('year') year?: string,
@Query('skip') skip?: string,
@Query('take') take?: string
) {
return this.familyOfficeService.getActivity({
entityId,
partnershipId,
skip: skip ? parseInt(skip, 10) : undefined,
take: take ? parseInt(take, 10) : undefined,
userId: this.request.user.id,
year: year ? parseInt(year, 10) : undefined
});
}
@Get('report')
@HasPermission(permissions.readEntity)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)

724
apps/api/src/app/family-office/family-office.service.ts

@ -4,10 +4,19 @@ import {
FamilyOfficeBenchmarkService
} from '@ghostfolio/api/services/benchmark/family-office-benchmark.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import {
getFamilyOfficeAssetTypeLabel
} from '@ghostfolio/common/helper';
import type {
IActivityDetail,
IActivityRow,
IAssetClassSummary,
IFamilyOfficeDashboard,
IFamilyOfficeReport
IFamilyOfficeReport,
IPerformanceRow,
IPortfolioSummary
} from '@ghostfolio/common/interfaces';
import type { K1Data } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable, Logger } from '@nestjs/common';
import Big from 'big.js';
@ -427,8 +436,706 @@ export class FamilyOfficeService {
};
}
// --- Portfolio Performance Views ---
/**
* T015: Get portfolio summary entity-level rollup of performance metrics.
* Supports optional quarterly granularity.
*/
public async getPortfolioSummary({
quarter,
userId,
valuationYear
}: {
quarter?: number;
userId: string;
valuationYear: number;
}): Promise<IPortfolioSummary> {
const yearEnd = this.computeValuationEndDate(valuationYear, quarter);
const entities = await this.prismaService.entity.findMany({
include: {
memberships: {
include: {
partnership: {
include: {
distributions: {
where: { date: { lte: yearEnd } }
},
valuations: {
orderBy: { date: 'desc' },
where: { date: { lte: yearEnd } },
take: 1
}
}
}
},
where: { endDate: null }
}
},
where: { userId }
});
const entityRows = entities.map((entity) => {
const row = this.computePerformanceRowForMemberships({
memberships: entity.memberships,
yearEnd
});
return {
...row,
entityId: entity.id,
entityName: entity.name
};
});
const totals = this.aggregatePerformanceRows(
entityRows
);
return {
entities: entityRows,
quarter,
totals,
valuationYear
};
}
/**
* T024: Get asset class summary group partnerships by dominant asset type.
* Supports optional quarterly granularity.
*/
public async getAssetClassSummary({
quarter,
userId,
valuationYear
}: {
quarter?: number;
userId: string;
valuationYear: number;
}): Promise<IAssetClassSummary> {
const yearEnd = this.computeValuationEndDate(valuationYear, quarter);
// Load all memberships with partnership details
const memberships =
await this.prismaService.partnershipMembership.findMany({
include: {
entity: true,
partnership: {
include: {
assets: true,
distributions: {
where: { date: { lte: yearEnd } }
},
valuations: {
orderBy: { date: 'desc' },
take: 1,
where: { date: { lte: yearEnd } }
}
}
}
},
where: {
endDate: null,
entity: { userId }
}
});
// Group memberships by partnership asset class
const groupedByAssetClass = new Map<
string,
typeof memberships
>();
for (const membership of memberships) {
const assetClass = this.determinePartnershipAssetClass(
membership.partnership.assets
);
if (!groupedByAssetClass.has(assetClass)) {
groupedByAssetClass.set(assetClass, []);
}
groupedByAssetClass.get(assetClass)!.push(membership);
}
const assetClassRows = Array.from(groupedByAssetClass.entries()).map(
([assetClass, classMemberships]) => {
const row = this.computePerformanceRowForMemberships({
memberships: classMemberships as any,
yearEnd
});
return {
...row,
assetClass,
assetClassLabel: getFamilyOfficeAssetTypeLabel(
assetClass as unknown as any
)
};
}
);
const totals = this.aggregatePerformanceRows(
assetClassRows
);
return {
assetClasses: assetClassRows,
quarter,
totals,
valuationYear
};
}
/**
* T032: Get activity detail per entity/partnership/year K-1 ledger.
*/
public async getActivity({
entityId,
partnershipId,
skip = 0,
take = 50,
userId,
year
}: {
entityId?: string;
partnershipId?: string;
skip?: number;
take?: number;
userId: string;
year?: number;
}): Promise<IActivityDetail> {
if (take > 200) {
take = 200;
}
// Get all user entities
const userEntities = await this.prismaService.entity.findMany({
select: { id: true, name: true },
where: { userId }
});
const entityIds = entityId
? [entityId]
: userEntities.map((e) => e.id);
// Get memberships linking entities to partnerships
const membershipWhere: any = {
endDate: null,
entityId: { in: entityIds }
};
if (partnershipId) {
membershipWhere.partnershipId = partnershipId;
}
const memberships =
await this.prismaService.partnershipMembership.findMany({
include: {
entity: { select: { id: true, name: true } },
partnership: {
select: { id: true, name: true }
}
},
where: membershipWhere
});
// Get K-documents for the relevant partnerships
const partnershipIds = [
...new Set(memberships.map((m) => m.partnershipId))
];
const kDocWhere: any = {
partnershipId: { in: partnershipIds },
type: 'K1'
};
if (year) {
kDocWhere.taxYear = year;
}
const kDocuments = await this.prismaService.kDocument.findMany({
where: kDocWhere
});
// Get distributions for relevant entities and partnerships
const distWhere: any = {
entityId: { in: entityIds }
};
if (partnershipId) {
distWhere.partnershipId = partnershipId;
}
const distributions = await this.prismaService.distribution.findMany({
where: distWhere
});
// Build activity rows
const rows: IActivityRow[] = [];
// Index K-docs by partnershipId+taxYear
const kDocIndex = new Map<string, typeof kDocuments[0]>();
for (const kDoc of kDocuments) {
kDocIndex.set(`${kDoc.partnershipId}_${kDoc.taxYear}`, kDoc);
}
// Collect all unique years from K-docs and distributions
const allYears = new Set<number>();
for (const kDoc of kDocuments) {
allYears.add(kDoc.taxYear);
}
for (const dist of distributions) {
allYears.add(dist.date.getFullYear());
}
const sortedYears = [...allYears].sort((a, b) => b - a);
const targetYears = year ? [year] : sortedYears;
for (const membership of memberships) {
for (const taxYear of targetYears) {
const kDoc = kDocIndex.get(
`${membership.partnershipId}_${taxYear}`
);
// Sum distributions for this entity+partnership+year
const yearDistributions = distributions.filter(
(d) =>
d.entityId === membership.entityId &&
d.partnershipId === membership.partnershipId &&
d.date.getFullYear() === taxYear
);
const distributionTotal = yearDistributions.reduce(
(sum, d) => sum + Number(d.amount),
0
);
// If no K-doc and no distributions for this combination, skip
if (!kDoc && yearDistributions.length === 0) {
continue;
}
const k1Data = (kDoc?.data as unknown as K1Data) ?? null;
const income = this.mapK1DataToIncomeComponents(k1Data);
const beginningBasis = k1Data?.beginningTaxBasis ?? 0;
const contributions =
taxYear === new Date(membership.effectiveDate).getFullYear()
? Number(membership.capitalContributed || 0)
: 0;
const otherAdj = k1Data?.otherAdjustments ?? 0;
const endingTaxBasis = k1Data?.endingTaxBasis ?? 0;
const endingGLBalance = k1Data?.endingGLBalance ?? null;
const k1CapitalAccount = k1Data?.k1CapitalAccount ?? null;
const derived = this.computeActivityDerivedFields({
beginningBasis,
contributions,
distributions: distributionTotal,
endingGLBalance,
endingTaxBasis,
k1CapitalAccount,
otherAdjustments: otherAdj,
totalIncome: income.totalIncome
});
rows.push({
beginningBasis,
bookToTaxAdj: derived.bookToTaxAdj,
capitalGains: income.capitalGains,
contributions,
deltaEndingBasis: derived.deltaEndingBasis,
distributions: distributionTotal,
dividends: income.dividends,
endingGLBalance,
endingK1CapitalAccount: k1CapitalAccount,
endingTaxBasis,
entityId: membership.entityId,
entityName: membership.entity.name,
excessDistribution: derived.excessDistribution,
interest: income.interest,
k1CapitalVsTaxBasisDiff: derived.k1CapitalVsTaxBasisDiff,
negativeBasis: derived.negativeBasis,
notes: k1Data?.activityNotes ?? null,
otherAdjustments: otherAdj,
partnershipId: membership.partnershipId,
partnershipName: membership.partnership.name,
remainingK1IncomeDed: income.remainingK1IncomeDed,
totalIncome: income.totalIncome,
year: taxYear
});
}
}
// Sort by year desc, then entity name, then partnership name
rows.sort((a, b) => {
if (a.year !== b.year) {
return b.year - a.year;
}
if (a.entityName !== b.entityName) {
return a.entityName.localeCompare(b.entityName);
}
return a.partnershipName.localeCompare(b.partnershipName);
});
const totalCount = rows.length;
const paginatedRows = rows.slice(skip, skip + take);
// Build filter options
const entityMap = new Map<string, string>();
const partnershipMap = new Map<string, string>();
const yearSet = new Set<number>();
for (const row of rows) {
entityMap.set(row.entityId, row.entityName);
partnershipMap.set(row.partnershipId, row.partnershipName);
yearSet.add(row.year);
}
return {
filters: {
entities: [...entityMap.entries()].map(([id, name]) => ({
id,
name
})),
partnerships: [...partnershipMap.entries()].map(([id, name]) => ({
id,
name
})),
years: [...yearSet].sort((a, b) => b - a)
},
rows: paginatedRows,
totalCount
};
}
// --- Private helpers ---
/**
* T009: Determine partnership's dominant asset class from its assets.
*/
private determinePartnershipAssetClass(
assets: { assetType: string }[]
): string {
if (!assets || assets.length === 0) {
return 'OTHER';
}
const typeCounts = new Map<string, number>();
for (const asset of assets) {
typeCounts.set(
asset.assetType,
(typeCounts.get(asset.assetType) ?? 0) + 1
);
}
let dominantType = 'OTHER';
let maxCount = 0;
for (const [assetType, count] of typeCounts) {
if (count > maxCount) {
maxCount = count;
dominantType = assetType;
}
}
return dominantType;
}
/**
* T010/T011: Compute IPerformanceRow from a set of memberships.
*/
private computePerformanceRowForMemberships({
memberships
}: {
memberships: {
capitalCommitment: any;
capitalContributed: any;
effectiveDate: Date;
ownershipPercent: any;
partnership: {
distributions: { amount: any; date: Date }[];
valuations: { nav: any; date: Date }[];
};
}[];
yearEnd: Date;
}): IPerformanceRow {
let totalCommitment = new Big(0);
let totalPaidIn = new Big(0);
let totalDistributions = new Big(0);
let totalResidual = new Big(0);
const allCashFlows: { amount: number; date: Date }[] = [];
for (const membership of memberships) {
const commitment = new Big(Number(membership.capitalCommitment || 0));
const contributed = new Big(Number(membership.capitalContributed || 0));
const ownershipPct = new Big(Number(membership.ownershipPercent || 0));
totalCommitment = totalCommitment.plus(commitment);
totalPaidIn = totalPaidIn.plus(contributed);
// Distributions through year-end
const distTotal = membership.partnership.distributions.reduce(
(sum, d) => sum.plus(Number(d.amount)),
new Big(0)
);
totalDistributions = totalDistributions.plus(distTotal);
// Latest NAV × ownership%
const latestValuation = membership.partnership.valuations[0];
const nav = latestValuation ? Number(latestValuation.nav) : 0;
const allocatedNav = new Big(nav)
.times(ownershipPct)
.div(100);
totalResidual = totalResidual.plus(allocatedNav);
// Build cash flows for XIRR
if (contributed.gt(0)) {
allCashFlows.push({
amount: -contributed.toNumber(),
date: membership.effectiveDate
});
}
for (const dist of membership.partnership.distributions) {
allCashFlows.push({
amount: Number(dist.amount),
date: dist.date
});
}
// Terminal NAV entry
if (latestValuation && allocatedNav.gt(0)) {
allCashFlows.push({
amount: allocatedNav.toNumber(),
date: latestValuation.date
});
}
}
const paidInNum = totalPaidIn.toNumber();
const distNum = totalDistributions.toNumber();
const residualNum = totalResidual.round(2).toNumber();
const commitmentNum = totalCommitment.toNumber();
const percentCalled =
commitmentNum > 0
? new Big(paidInNum).div(commitmentNum).times(100).round(2).toNumber()
: null;
const dpi = FamilyOfficePerformanceCalculator.computeDPI(
distNum,
paidInNum
);
const rvpi = FamilyOfficePerformanceCalculator.computeRVPI(
residualNum,
paidInNum
);
const tvpi = FamilyOfficePerformanceCalculator.computeTVPI(
distNum,
residualNum,
paidInNum
);
const irr = FamilyOfficePerformanceCalculator.computeXIRR(allCashFlows);
return {
distributions: Math.round(distNum * 100) / 100,
dpi,
irr,
originalCommitment: Math.round(commitmentNum * 100) / 100,
paidIn: Math.round(paidInNum * 100) / 100,
percentCalled,
residualUsed: residualNum,
rvpi,
tvpi,
unfundedCommitment: Math.round(
new Big(commitmentNum).minus(paidInNum).toNumber() * 100
) / 100
};
}
/**
* Build aggregate totals row from individual performance rows.
*/
private aggregatePerformanceRows(
rows: IPerformanceRow[]
): IPerformanceRow {
if (rows.length === 0) {
return {
distributions: 0,
dpi: 0,
irr: null,
originalCommitment: 0,
paidIn: 0,
percentCalled: null,
residualUsed: 0,
rvpi: 0,
tvpi: 0,
unfundedCommitment: 0
};
}
const totalCommitment = rows.reduce(
(sum, r) => sum + r.originalCommitment,
0
);
const totalPaidIn = rows.reduce((sum, r) => sum + r.paidIn, 0);
const totalDist = rows.reduce((sum, r) => sum + r.distributions, 0);
const totalResidual = rows.reduce((sum, r) => sum + r.residualUsed, 0);
const percentCalled =
totalCommitment > 0
? Math.round(
new Big(totalPaidIn)
.div(totalCommitment)
.times(100)
.toNumber() * 100
) / 100
: null;
return {
distributions: Math.round(totalDist * 100) / 100,
dpi: FamilyOfficePerformanceCalculator.computeDPI(
totalDist,
totalPaidIn
),
irr: null, // Aggregate XIRR requires re-computation from all cash flows
originalCommitment: Math.round(totalCommitment * 100) / 100,
paidIn: Math.round(totalPaidIn * 100) / 100,
percentCalled,
residualUsed: Math.round(totalResidual * 100) / 100,
rvpi: FamilyOfficePerformanceCalculator.computeRVPI(
totalResidual,
totalPaidIn
),
tvpi: FamilyOfficePerformanceCalculator.computeTVPI(
totalDist,
totalResidual,
totalPaidIn
),
unfundedCommitment: Math.round(
(totalCommitment - totalPaidIn) * 100
) / 100
};
}
/**
* T012: Map K1Data fields to activity row income components.
*/
private mapK1DataToIncomeComponents(k1Data: K1Data | null): {
capitalGains: number;
dividends: number;
interest: number;
remainingK1IncomeDed: number;
totalIncome: number;
} {
if (!k1Data) {
return {
capitalGains: 0,
dividends: 0,
interest: 0,
remainingK1IncomeDed: 0,
totalIncome: 0
};
}
const interest = k1Data.interestIncome ?? 0;
const dividends = k1Data.dividends ?? 0;
const capitalGains =
(k1Data.capitalGainLossShortTerm ?? 0) +
(k1Data.capitalGainLossLongTerm ?? 0) +
(k1Data.unrecaptured1250Gain ?? 0) +
(k1Data.section1231GainLoss ?? 0);
const remainingK1IncomeDed =
(k1Data.ordinaryIncome ?? 0) +
(k1Data.netRentalIncome ?? 0) +
(k1Data.otherRentalIncome ?? 0) +
(k1Data.guaranteedPayments ?? 0) +
(k1Data.royalties ?? 0) +
(k1Data.otherIncome ?? 0) +
(k1Data.selfEmploymentEarnings ?? 0) -
(k1Data.section179Deduction ?? 0) -
(k1Data.otherDeductions ?? 0) -
(k1Data.foreignTaxesPaid ?? 0);
const totalIncome = interest + dividends + capitalGains + remainingK1IncomeDed;
return {
capitalGains: Math.round(capitalGains * 100) / 100,
dividends: Math.round(dividends * 100) / 100,
interest: Math.round(interest * 100) / 100,
remainingK1IncomeDed: Math.round(remainingK1IncomeDed * 100) / 100,
totalIncome: Math.round(totalIncome * 100) / 100
};
}
/**
* T013: Compute activity row derived fields.
*/
private computeActivityDerivedFields({
beginningBasis,
contributions,
distributions,
endingGLBalance,
endingTaxBasis,
k1CapitalAccount,
otherAdjustments,
totalIncome
}: {
beginningBasis: number;
contributions: number;
distributions: number;
endingGLBalance: number | null;
endingTaxBasis: number;
k1CapitalAccount: number | null;
otherAdjustments: number;
totalIncome: number;
}): {
bookToTaxAdj: number | null;
deltaEndingBasis: number;
excessDistribution: number;
k1CapitalVsTaxBasisDiff: number | null;
negativeBasis: boolean;
} {
const bookToTaxAdj =
endingGLBalance !== null ? endingGLBalance - endingTaxBasis : null;
const k1CapitalVsTaxBasisDiff =
k1CapitalAccount !== null ? k1CapitalAccount - endingTaxBasis : null;
const excessDistributionCalc =
distributions -
(beginningBasis + contributions + totalIncome + otherAdjustments);
const excessDistribution = Math.max(0, excessDistributionCalc);
const negativeBasis = endingTaxBasis < 0;
const deltaEndingBasis = endingTaxBasis - beginningBasis;
return {
bookToTaxAdj:
bookToTaxAdj !== null ? Math.round(bookToTaxAdj * 100) / 100 : null,
deltaEndingBasis: Math.round(deltaEndingBasis * 100) / 100,
excessDistribution: Math.round(excessDistribution * 100) / 100,
k1CapitalVsTaxBasisDiff:
k1CapitalVsTaxBasisDiff !== null
? Math.round(k1CapitalVsTaxBasisDiff * 100) / 100
: null,
negativeBasis
};
}
private computePeriodDates({
period,
periodNumber,
@ -459,6 +1166,21 @@ export class FamilyOfficeService {
return { endDate, startDate };
}
/**
* Compute the end date for a valuation period.
* If quarter is provided (1-4), returns the last day of that quarter.
* Otherwise returns Dec 31 of the year.
*/
private computeValuationEndDate(year: number, quarter?: number): Date {
if (quarter && quarter >= 1 && quarter <= 4) {
// Last day of the quarter: Q1=Mar 31, Q2=Jun 30, Q3=Sep 30, Q4=Dec 31
const endMonth = quarter * 3; // 3,6,9,12
return new Date(year, endMonth, 0); // Day 0 of next month = last day
}
return new Date(year, 11, 31);
}
private async getUserPartnerships({
entityId,
userId

7
apps/client/src/app/app.routes.ts

@ -195,6 +195,13 @@ export const routes: Routes = [
(m) => m.routes
)
},
{
path: 'portfolio-views',
loadChildren: () =>
import('./pages/portfolio-views/portfolio-views-page.routes').then(
(m) => m.routes
)
},
{
// wildcard, if requested url doesn't match any paths for routes defined
// earlier

52
apps/client/src/app/components/admin-overview/admin-overview.component.ts

@ -46,6 +46,8 @@ import {
closeCircleOutline,
ellipsisHorizontal,
informationCircleOutline,
nuclearOutline,
sparklesOutline,
syncOutline,
trashOutline
} from 'ionicons/icons';
@ -133,6 +135,8 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit {
closeCircleOutline,
ellipsisHorizontal,
informationCircleOutline,
nuclearOutline,
sparklesOutline,
syncOutline,
trashOutline
});
@ -231,6 +235,54 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit {
});
}
public onClearFamilyOfficeData() {
this.notificationService.confirm({
confirmFn: () => {
this.adminService
.clearFamilyOfficeData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ deleted }) => {
const total = Object.values(deleted).reduce((a, b) => a + b, 0);
this.snackBar.open(
'✅ ' +
$localize`Family office data cleared (${total} records removed).`,
undefined,
{
duration: ms('3 seconds')
}
);
});
},
confirmType: ConfirmationDialogType.Warn,
title: $localize`Do you really want to clear all family office data?`
});
}
public onPopulateDummyData() {
this.notificationService.confirm({
confirmFn: () => {
this.adminService
.seedFamilyOfficeData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ created }) => {
const total = Object.values(created).reduce((a, b) => a + b, 0);
this.snackBar.open(
'✅ ' +
$localize`Dummy data populated (${total} records created).`,
undefined,
{
duration: ms('3 seconds')
}
);
});
},
confirmType: ConfirmationDialogType.Warn,
title: $localize`Do you really want to populate dummy family office data?`
});
}
public onEnableUserSignupModeChange(aEvent: MatSlideToggleChange) {
this.putAdminSetting({
key: PROPERTY_IS_USER_SIGNUP_ENABLED,

18
apps/client/src/app/components/admin-overview/admin-overview.html

@ -199,6 +199,24 @@
<ion-icon class="mr-1" name="close-circle-outline" />
<span i18n>Flush Cache</span>
</button>
<button
class="mt-2"
color="warn"
mat-flat-button
(click)="onClearFamilyOfficeData()"
>
<ion-icon class="mr-1" name="nuclear-outline" />
<span i18n>Clear Family Office Data</span>
</button>
<button
class="mt-2"
color="accent"
mat-flat-button
(click)="onPopulateDummyData()"
>
<ion-icon class="mr-1" name="sparkles-outline" />
<span i18n>Populate Dummy Data</span>
</button>
</div>
</div>
</div>

21
apps/client/src/app/components/header/header.component.html

@ -110,6 +110,19 @@
>K-1 Documents</a
>
</li>
<li class="list-inline-item">
<a
class="d-none d-sm-block"
i18n
mat-flat-button
routerLink="/portfolio-views"
[ngClass]="{
'font-weight-bold': currentRoute === 'portfolio-views',
'text-decoration-underline': currentRoute === 'portfolio-views'
}"
>Portfolio Views</a
>
</li>
@if (hasPermissionToAccessAdminControl) {
<li class="list-inline-item">
<a
@ -351,6 +364,14 @@
[ngClass]="{ 'font-weight-bold': currentRoute === 'k-documents' }"
>K-1 Documents</a
>
<a
class="d-flex d-sm-none"
i18n
mat-menu-item
routerLink="/portfolio-views"
[ngClass]="{ 'font-weight-bold': currentRoute === 'portfolio-views' }"
>Portfolio Views</a
>
<a
i18n
mat-menu-item

591
apps/client/src/app/pages/portfolio-views/portfolio-views-page.component.ts

@ -0,0 +1,591 @@
import { FamilyOfficeDataService } from '@ghostfolio/client/services/family-office-data.service';
import type {
IActivityDetail,
IAssetClassSummary,
IPortfolioSummary
} from '@ghostfolio/common/interfaces';
import { GfAccountingNumberPipe } from '@ghostfolio/common/pipes';
import { CommonModule, DecimalPipe, PercentPipe } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatTableModule } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { MatTooltipModule } from '@angular/material/tooltip';
import { Router } from '@angular/router';
export type PeriodMode = 'QUARTERLY' | 'YEARLY' | 'ALL_TIME';
export interface ComparisonDelta {
label: string;
primaryValue: number | null;
comparisonValue: number | null;
delta: number | null;
deltaPercent: number | null;
format: 'currency' | 'multiple' | 'percent';
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
DecimalPipe,
FormsModule,
GfAccountingNumberPipe,
MatButtonToggleModule,
MatFormFieldModule,
MatIconModule,
MatPaginatorModule,
MatProgressSpinnerModule,
MatSelectModule,
MatSlideToggleModule,
MatTableModule,
MatTabsModule,
MatTooltipModule,
PercentPipe
],
selector: 'gf-portfolio-views-page',
standalone: true,
templateUrl: './portfolio-views-page.html',
styleUrls: ['./portfolio-views-page.scss']
})
export class PortfolioViewsPageComponent implements OnInit {
// Period mode
public periodMode: PeriodMode = 'YEARLY';
public valuationYear: number = new Date().getFullYear();
public selectedQuarter: number = Math.ceil(
(new Date().getMonth() + 1) / 3
);
public availableYears: number[] = [];
public availableQuarters = [
{ value: 1, label: 'Q1 (Jan–Mar)' },
{ value: 2, label: 'Q2 (Apr–Jun)' },
{ value: 3, label: 'Q3 (Jul–Sep)' },
{ value: 4, label: 'Q4 (Oct–Dec)' }
];
// Comparison
public comparisonEnabled = false;
public comparisonYear: number = new Date().getFullYear() - 1;
public comparisonQuarter: number = 1;
public comparisonPortfolioSummary: IPortfolioSummary | null = null;
public comparisonAssetClassSummary: IAssetClassSummary | null = null;
public isLoadingComparisonPortfolio = false;
public isLoadingComparisonAssetClass = false;
public portfolioDeltas: ComparisonDelta[] = [];
public assetClassDeltas: ComparisonDelta[] = [];
// Tab state
public activeTabIndex = 0;
// Portfolio Summary (US1)
public portfolioSummary: IPortfolioSummary | null = null;
public portfolioSummaryColumns: string[] = [
'entityName',
'originalCommitment',
'percentCalled',
'unfundedCommitment',
'paidIn',
'distributions',
'residualUsed',
'dpi',
'rvpi',
'tvpi',
'irr'
];
// Asset Class Summary (US2)
public assetClassSummary: IAssetClassSummary | null = null;
public assetClassSummaryColumns: string[] = [
'assetClassLabel',
'originalCommitment',
'percentCalled',
'unfundedCommitment',
'paidIn',
'distributions',
'residualUsed',
'dpi',
'rvpi',
'tvpi',
'irr'
];
// Activity Detail (US3)
public activityDetail: IActivityDetail | null = null;
public activityColumns: string[] = [
'year',
'entityName',
'partnershipName',
'beginningBasis',
'contributions',
'interest',
'dividends',
'capitalGains',
'remainingK1IncomeDed',
'totalIncome',
'distributions',
'otherAdjustments',
'endingTaxBasis',
'endingGLBalance',
'bookToTaxAdj',
'endingK1CapitalAccount',
'k1CapitalVsTaxBasisDiff',
'excessDistribution',
'negativeBasis',
'deltaEndingBasis',
'notes'
];
public activityEntityFilter: string = '';
public activityPartnershipFilter: string = '';
public activityYearFilter: number | null = null;
public activityPageIndex = 0;
public activityPageSize = 50;
// Loading states
public isLoadingPortfolio = false;
public isLoadingAssetClass = false;
public isLoadingActivity = false;
public constructor(
private readonly changeDetectorRef: ChangeDetectorRef,
private readonly destroyRef: DestroyRef,
private readonly familyOfficeDataService: FamilyOfficeDataService,
private readonly router: Router
) {}
public ngOnInit() {
const currentYear = new Date().getFullYear();
for (let y = currentYear; y >= currentYear - 10; y--) {
this.availableYears.push(y);
}
this.comparisonYear = currentYear - 1;
this.comparisonQuarter = this.selectedQuarter;
this.loadPortfolioSummary();
}
// ── Period controls ───────────────────────────────────────────────
public get periodLabel(): string {
return this.buildPeriodLabel(
this.periodMode,
this.valuationYear,
this.selectedQuarter
);
}
public get comparisonPeriodLabel(): string {
return this.buildPeriodLabel(
this.periodMode,
this.comparisonYear,
this.comparisonQuarter
);
}
public onPeriodModeChange() {
this.resetData();
this.loadActiveTab();
}
public onValuationYearChange() {
this.resetData();
this.loadActiveTab();
}
public onQuarterChange() {
this.resetData();
this.loadActiveTab();
}
public onComparisonToggle() {
if (this.comparisonEnabled) {
this.loadComparisonData();
} else {
this.comparisonPortfolioSummary = null;
this.comparisonAssetClassSummary = null;
this.portfolioDeltas = [];
this.assetClassDeltas = [];
}
}
public onComparisonPeriodChange() {
if (this.comparisonEnabled) {
this.loadComparisonData();
}
}
public onTabChange(index: number) {
this.activeTabIndex = index;
switch (index) {
case 0:
if (!this.portfolioSummary) {
this.loadPortfolioSummary();
}
if (this.comparisonEnabled && !this.comparisonPortfolioSummary) {
this.loadComparisonPortfolioSummary();
}
break;
case 1:
if (!this.assetClassSummary) {
this.loadAssetClassSummary();
}
if (this.comparisonEnabled && !this.comparisonAssetClassSummary) {
this.loadComparisonAssetClassSummary();
}
break;
case 2:
if (!this.activityDetail) {
this.loadActivity();
}
break;
}
}
public onEntityRowClick(entityId: string) {
this.router.navigate(['/entities', entityId]);
}
public onAssetClassRowClick(_assetClass: string) {
// TODO: Implement drill-down into partnerships for this asset class
}
public onActivityFilterChange() {
this.activityPageIndex = 0;
this.loadActivity();
}
public onActivityPageChange(event: PageEvent) {
this.activityPageIndex = event.pageIndex;
this.activityPageSize = event.pageSize;
this.loadActivity();
}
// ── Helpers ───────────────────────────────────────────────────────
public getDeltaClass(delta: number | null): string {
if (delta === null || delta === 0) {
return 'delta-neutral';
}
return delta > 0 ? 'delta-positive' : 'delta-negative';
}
public getDeltaIcon(delta: number | null): string {
if (delta === null || delta === 0) {
return 'remove';
}
return delta > 0 ? 'arrow_upward' : 'arrow_downward';
}
// ── Private ───────────────────────────────────────────────────────
private buildPeriodLabel(
mode: PeriodMode,
year: number,
quarter: number
): string {
switch (mode) {
case 'QUARTERLY':
return `Q${quarter} ${year}`;
case 'YEARLY':
return `${year}`;
case 'ALL_TIME':
return 'All-Time';
}
}
private buildFetchParams(
year: number,
quarter: number
): { valuationYear?: number; quarter?: number } {
switch (this.periodMode) {
case 'QUARTERLY':
return { valuationYear: year, quarter };
case 'YEARLY':
return { valuationYear: year };
case 'ALL_TIME':
return { valuationYear: new Date().getFullYear() };
}
}
private get primaryParams() {
return this.buildFetchParams(this.valuationYear, this.selectedQuarter);
}
private get comparisonParams() {
return this.buildFetchParams(this.comparisonYear, this.comparisonQuarter);
}
private resetData() {
this.portfolioSummary = null;
this.assetClassSummary = null;
this.activityDetail = null;
this.comparisonPortfolioSummary = null;
this.comparisonAssetClassSummary = null;
this.portfolioDeltas = [];
this.assetClassDeltas = [];
}
private loadActiveTab() {
switch (this.activeTabIndex) {
case 0:
this.loadPortfolioSummary();
if (this.comparisonEnabled) {
this.loadComparisonPortfolioSummary();
}
break;
case 1:
this.loadAssetClassSummary();
if (this.comparisonEnabled) {
this.loadComparisonAssetClassSummary();
}
break;
case 2:
this.loadActivity();
break;
}
}
private loadComparisonData() {
switch (this.activeTabIndex) {
case 0:
this.loadComparisonPortfolioSummary();
break;
case 1:
this.loadComparisonAssetClassSummary();
break;
}
}
private loadPortfolioSummary() {
this.isLoadingPortfolio = true;
this.changeDetectorRef.markForCheck();
this.familyOfficeDataService
.fetchPortfolioSummary(this.primaryParams)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.isLoadingPortfolio = false;
this.changeDetectorRef.markForCheck();
},
next: (data) => {
this.portfolioSummary = data;
this.isLoadingPortfolio = false;
this.computePortfolioDeltas();
this.changeDetectorRef.markForCheck();
}
});
}
private loadAssetClassSummary() {
this.isLoadingAssetClass = true;
this.changeDetectorRef.markForCheck();
this.familyOfficeDataService
.fetchAssetClassSummary(this.primaryParams)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.isLoadingAssetClass = false;
this.changeDetectorRef.markForCheck();
},
next: (data) => {
this.assetClassSummary = data;
this.isLoadingAssetClass = false;
this.computeAssetClassDeltas();
this.changeDetectorRef.markForCheck();
}
});
}
private loadComparisonPortfolioSummary() {
this.isLoadingComparisonPortfolio = true;
this.changeDetectorRef.markForCheck();
this.familyOfficeDataService
.fetchPortfolioSummary(this.comparisonParams)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.isLoadingComparisonPortfolio = false;
this.changeDetectorRef.markForCheck();
},
next: (data) => {
this.comparisonPortfolioSummary = data;
this.isLoadingComparisonPortfolio = false;
this.computePortfolioDeltas();
this.changeDetectorRef.markForCheck();
}
});
}
private loadComparisonAssetClassSummary() {
this.isLoadingComparisonAssetClass = true;
this.changeDetectorRef.markForCheck();
this.familyOfficeDataService
.fetchAssetClassSummary(this.comparisonParams)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.isLoadingComparisonAssetClass = false;
this.changeDetectorRef.markForCheck();
},
next: (data) => {
this.comparisonAssetClassSummary = data;
this.isLoadingComparisonAssetClass = false;
this.computeAssetClassDeltas();
this.changeDetectorRef.markForCheck();
}
});
}
private computePortfolioDeltas() {
if (!this.portfolioSummary || !this.comparisonPortfolioSummary) {
this.portfolioDeltas = [];
return;
}
const primary = this.portfolioSummary.totals;
const comparison = this.comparisonPortfolioSummary.totals;
this.portfolioDeltas = [
this.buildDelta(
'Paid-In',
primary.paidIn,
comparison.paidIn,
'currency'
),
this.buildDelta(
'Distributions',
primary.distributions,
comparison.distributions,
'currency'
),
this.buildDelta(
'Residual (NAV)',
primary.residualUsed,
comparison.residualUsed,
'currency'
),
this.buildDelta(
'Unfunded',
primary.unfundedCommitment,
comparison.unfundedCommitment,
'currency'
),
this.buildDelta('DPI', primary.dpi, comparison.dpi, 'multiple'),
this.buildDelta('RVPI', primary.rvpi, comparison.rvpi, 'multiple'),
this.buildDelta('TVPI', primary.tvpi, comparison.tvpi, 'multiple'),
this.buildDelta('IRR', primary.irr, comparison.irr, 'percent')
];
}
private computeAssetClassDeltas() {
if (!this.assetClassSummary || !this.comparisonAssetClassSummary) {
this.assetClassDeltas = [];
return;
}
const primary = this.assetClassSummary.totals;
const comparison = this.comparisonAssetClassSummary.totals;
this.assetClassDeltas = [
this.buildDelta(
'Paid-In',
primary.paidIn,
comparison.paidIn,
'currency'
),
this.buildDelta(
'Distributions',
primary.distributions,
comparison.distributions,
'currency'
),
this.buildDelta(
'Residual (NAV)',
primary.residualUsed,
comparison.residualUsed,
'currency'
),
this.buildDelta('DPI', primary.dpi, comparison.dpi, 'multiple'),
this.buildDelta('TVPI', primary.tvpi, comparison.tvpi, 'multiple'),
this.buildDelta('IRR', primary.irr, comparison.irr, 'percent')
];
}
private buildDelta(
label: string,
primaryValue: number | null,
comparisonValue: number | null,
format: 'currency' | 'multiple' | 'percent'
): ComparisonDelta {
let delta: number | null = null;
let deltaPercent: number | null = null;
if (primaryValue !== null && comparisonValue !== null) {
delta = primaryValue - comparisonValue;
if (comparisonValue !== 0) {
deltaPercent = delta / Math.abs(comparisonValue);
}
}
return { label, primaryValue, comparisonValue, delta, deltaPercent, format };
}
private loadActivity() {
this.isLoadingActivity = true;
this.changeDetectorRef.markForCheck();
const params: Record<string, unknown> = {
skip: this.activityPageIndex * this.activityPageSize,
take: this.activityPageSize
};
if (this.activityEntityFilter) {
params['entityId'] = this.activityEntityFilter;
}
if (this.activityPartnershipFilter) {
params['partnershipId'] = this.activityPartnershipFilter;
}
if (this.activityYearFilter) {
params['year'] = this.activityYearFilter;
}
this.familyOfficeDataService
.fetchActivity(params as any)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.isLoadingActivity = false;
this.changeDetectorRef.markForCheck();
},
next: (data) => {
this.activityDetail = data;
this.isLoadingActivity = false;
this.changeDetectorRef.markForCheck();
}
});
}
}

692
apps/client/src/app/pages/portfolio-views/portfolio-views-page.html

@ -0,0 +1,692 @@
<div class="container">
<!-- Page Header with Period Controls -->
<div class="page-header">
<h1 i18n>Portfolio Views</h1>
<div class="period-controls">
<!-- Period Mode Toggle -->
<mat-button-toggle-group
[(ngModel)]="periodMode"
(change)="onPeriodModeChange()"
class="period-mode-toggle"
appearance="standard"
>
<mat-button-toggle value="QUARTERLY">Quarterly</mat-button-toggle>
<mat-button-toggle value="YEARLY">Yearly</mat-button-toggle>
<mat-button-toggle value="ALL_TIME">All-Time</mat-button-toggle>
</mat-button-toggle-group>
<!-- Year Selector (hidden for ALL_TIME) -->
@if (periodMode !== 'ALL_TIME') {
<mat-form-field appearance="outline" class="year-filter">
<mat-label i18n>Year</mat-label>
<mat-select
[(ngModel)]="valuationYear"
(selectionChange)="onValuationYearChange()"
>
@for (year of availableYears; track year) {
<mat-option [value]="year">{{ year }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<!-- Quarter Selector (only for QUARTERLY) -->
@if (periodMode === 'QUARTERLY') {
<mat-form-field appearance="outline" class="quarter-filter">
<mat-label i18n>Quarter</mat-label>
<mat-select
[(ngModel)]="selectedQuarter"
(selectionChange)="onQuarterChange()"
>
@for (q of availableQuarters; track q.value) {
<mat-option [value]="q.value">{{ q.label }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<!-- Comparison Toggle -->
@if (periodMode !== 'ALL_TIME') {
<div class="comparison-toggle">
<mat-slide-toggle
[(ngModel)]="comparisonEnabled"
(change)="onComparisonToggle()"
color="primary"
>
Compare
</mat-slide-toggle>
</div>
}
</div>
</div>
<!-- Comparison Period Selector -->
@if (comparisonEnabled && periodMode !== 'ALL_TIME') {
<div class="comparison-bar">
<mat-icon class="compare-icon">compare_arrows</mat-icon>
<span class="compare-label" i18n>Comparing <strong>{{ periodLabel }}</strong> vs</span>
<mat-form-field appearance="outline" class="year-filter">
<mat-label i18n>Year</mat-label>
<mat-select
[(ngModel)]="comparisonYear"
(selectionChange)="onComparisonPeriodChange()"
>
@for (year of availableYears; track year) {
<mat-option [value]="year">{{ year }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (periodMode === 'QUARTERLY') {
<mat-form-field appearance="outline" class="quarter-filter">
<mat-label i18n>Quarter</mat-label>
<mat-select
[(ngModel)]="comparisonQuarter"
(selectionChange)="onComparisonPeriodChange()"
>
@for (q of availableQuarters; track q.value) {
<mat-option [value]="q.value">{{ q.label }}</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
}
<mat-tab-group
[selectedIndex]="activeTabIndex"
(selectedIndexChange)="onTabChange($event)"
animationDuration="200ms"
mat-stretch-tabs="false"
>
<!-- Tab 1: Portfolio Summary -->
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">account_balance</mat-icon>
<span i18n>Portfolio Summary</span>
</ng-template>
<!-- Comparison Delta Panel -->
@if (comparisonEnabled && portfolioDeltas.length > 0) {
<div class="comparison-panel">
<div class="comparison-panel-header">
<span class="period-badge primary-badge">{{ periodLabel }}</span>
<mat-icon>compare_arrows</mat-icon>
<span class="period-badge comparison-badge">{{ comparisonPeriodLabel }}</span>
</div>
<div class="delta-grid">
@for (d of portfolioDeltas; track d.label) {
<div class="delta-card" [class]="getDeltaClass(d.delta)">
<div class="delta-label">{{ d.label }}</div>
<div class="delta-values">
<span class="delta-primary">
@switch (d.format) {
@case ('currency') { {{ d.primaryValue | gfAccountingNumber }} }
@case ('multiple') { {{ d.primaryValue | number:'1.2-2' }}x }
@case ('percent') {
@if (d.primaryValue !== null) {
{{ d.primaryValue | percent:'1.2-2' }}
} @else { N/A }
}
}
</span>
<span class="delta-vs">vs</span>
<span class="delta-comparison">
@switch (d.format) {
@case ('currency') { {{ d.comparisonValue | gfAccountingNumber }} }
@case ('multiple') { {{ d.comparisonValue | number:'1.2-2' }}x }
@case ('percent') {
@if (d.comparisonValue !== null) {
{{ d.comparisonValue | percent:'1.2-2' }}
} @else { N/A }
}
}
</span>
</div>
<div class="delta-change" [class]="getDeltaClass(d.delta)">
@if (d.delta !== null) {
<mat-icon class="delta-icon">{{ getDeltaIcon(d.delta) }}</mat-icon>
@switch (d.format) {
@case ('currency') { {{ d.delta | gfAccountingNumber }} }
@case ('multiple') { {{ d.delta | number:'1.2-2' }} }
@case ('percent') { {{ d.delta | percent:'1.2-2' }} }
}
@if (d.deltaPercent !== null) {
<span class="delta-pct">({{ d.deltaPercent | percent:'1.1-1' }})</span>
}
} @else {
<span></span>
}
</div>
</div>
}
</div>
</div>
}
@if (isLoadingPortfolio) {
<div class="loading-container">
<mat-spinner diameter="48"></mat-spinner>
</div>
}
@if (!isLoadingPortfolio && portfolioSummary) {
@if (portfolioSummary.entities.length === 0) {
<div class="empty-state">
<mat-icon>info_outline</mat-icon>
<p i18n>No portfolio data available for {{ periodLabel }}.</p>
</div>
} @else {
<div class="table-container">
<table mat-table [dataSource]="portfolioSummary.entities" class="performance-table">
<ng-container matColumnDef="entityName">
<th mat-header-cell *matHeaderCellDef i18n>Entity</th>
<td mat-cell *matCellDef="let row" class="entity-name clickable">{{ row.entityName }}</td>
<td mat-footer-cell *matFooterCellDef class="totals-label"><strong i18n>All Entities</strong></td>
</ng-container>
<ng-container matColumnDef="originalCommitment">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Original Commitment</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.originalCommitment | gfAccountingNumber }}</td>
<td mat-footer-cell *matFooterCellDef class="numeric totals-value"><strong>{{ portfolioSummary.totals.originalCommitment | gfAccountingNumber }}</strong></td>
</ng-container>
<ng-container matColumnDef="percentCalled">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>% Called</th>
<td mat-cell *matCellDef="let row" class="numeric">
@if (row.percentCalled !== null) {
{{ row.percentCalled / 100 | percent:'1.0-0' }}
} @else {
N/A
}
</td>
<td mat-footer-cell *matFooterCellDef class="numeric totals-value">
<strong>
@if (portfolioSummary.totals.percentCalled !== null) {
{{ portfolioSummary.totals.percentCalled / 100 | percent:'1.0-0' }}
} @else {
N/A
}
</strong>
</td>
</ng-container>
<ng-container matColumnDef="unfundedCommitment">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Unfunded</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.unfundedCommitment | gfAccountingNumber }}</td>
<td mat-footer-cell *matFooterCellDef class="numeric totals-value"><strong>{{ portfolioSummary.totals.unfundedCommitment | gfAccountingNumber }}</strong></td>
</ng-container>
<ng-container matColumnDef="paidIn">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Paid-In</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.paidIn | gfAccountingNumber }}</td>
<td mat-footer-cell *matFooterCellDef class="numeric totals-value"><strong>{{ portfolioSummary.totals.paidIn | gfAccountingNumber }}</strong></td>
</ng-container>
<ng-container matColumnDef="distributions">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Distributions</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.distributions | gfAccountingNumber }}</td>
<td mat-footer-cell *matFooterCellDef class="numeric totals-value"><strong>{{ portfolioSummary.totals.distributions | gfAccountingNumber }}</strong></td>
</ng-container>
<ng-container matColumnDef="residualUsed">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Residual Used</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.residualUsed | gfAccountingNumber }}</td>
<td mat-footer-cell *matFooterCellDef class="numeric totals-value"><strong>{{ portfolioSummary.totals.residualUsed | gfAccountingNumber }}</strong></td>
</ng-container>
<ng-container matColumnDef="dpi">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>DPI</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.dpi | number:'1.2-2' }}</td>
<td mat-footer-cell *matFooterCellDef class="numeric totals-value"><strong>{{ portfolioSummary.totals.dpi | number:'1.2-2' }}</strong></td>
</ng-container>
<ng-container matColumnDef="rvpi">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>RVPI</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.rvpi | number:'1.2-2' }}</td>
<td mat-footer-cell *matFooterCellDef class="numeric totals-value"><strong>{{ portfolioSummary.totals.rvpi | number:'1.2-2' }}</strong></td>
</ng-container>
<ng-container matColumnDef="tvpi">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>TVPI</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.tvpi | number:'1.2-2' }}</td>
<td mat-footer-cell *matFooterCellDef class="numeric totals-value"><strong>{{ portfolioSummary.totals.tvpi | number:'1.2-2' }}</strong></td>
</ng-container>
<ng-container matColumnDef="irr">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>IRR</th>
<td mat-cell *matCellDef="let row" class="numeric">
@if (row.irr !== null) {
{{ row.irr | percent:'1.2-2' }}
} @else {
N/A
}
</td>
<td mat-footer-cell *matFooterCellDef class="numeric totals-value">
<strong>
@if (portfolioSummary.totals.irr !== null) {
{{ portfolioSummary.totals.irr | percent:'1.2-2' }}
} @else {
N/A
}
</strong>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="portfolioSummaryColumns"></tr>
<tr mat-row *matRowDef="let row; columns: portfolioSummaryColumns;"
class="clickable-row"
(click)="onEntityRowClick(row.entityId)"></tr>
<tr mat-footer-row *matFooterRowDef="portfolioSummaryColumns" class="totals-row"></tr>
</table>
</div>
}
}
</mat-tab>
<!-- Tab 2: Asset Class Summary -->
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">pie_chart</mat-icon>
<span i18n>Asset Class Summary</span>
</ng-template>
<!-- Comparison Delta Panel -->
@if (comparisonEnabled && assetClassDeltas.length > 0) {
<div class="comparison-panel">
<div class="comparison-panel-header">
<span class="period-badge primary-badge">{{ periodLabel }}</span>
<mat-icon>compare_arrows</mat-icon>
<span class="period-badge comparison-badge">{{ comparisonPeriodLabel }}</span>
</div>
<div class="delta-grid">
@for (d of assetClassDeltas; track d.label) {
<div class="delta-card" [class]="getDeltaClass(d.delta)">
<div class="delta-label">{{ d.label }}</div>
<div class="delta-values">
<span class="delta-primary">
@switch (d.format) {
@case ('currency') { {{ d.primaryValue | gfAccountingNumber }} }
@case ('multiple') { {{ d.primaryValue | number:'1.2-2' }}x }
@case ('percent') {
@if (d.primaryValue !== null) {
{{ d.primaryValue | percent:'1.2-2' }}
} @else { N/A }
}
}
</span>
<span class="delta-vs">vs</span>
<span class="delta-comparison">
@switch (d.format) {
@case ('currency') { {{ d.comparisonValue | gfAccountingNumber }} }
@case ('multiple') { {{ d.comparisonValue | number:'1.2-2' }}x }
@case ('percent') {
@if (d.comparisonValue !== null) {
{{ d.comparisonValue | percent:'1.2-2' }}
} @else { N/A }
}
}
</span>
</div>
<div class="delta-change" [class]="getDeltaClass(d.delta)">
@if (d.delta !== null) {
<mat-icon class="delta-icon">{{ getDeltaIcon(d.delta) }}</mat-icon>
@switch (d.format) {
@case ('currency') { {{ d.delta | gfAccountingNumber }} }
@case ('multiple') { {{ d.delta | number:'1.2-2' }} }
@case ('percent') { {{ d.delta | percent:'1.2-2' }} }
}
@if (d.deltaPercent !== null) {
<span class="delta-pct">({{ d.deltaPercent | percent:'1.1-1' }})</span>
}
} @else {
<span></span>
}
</div>
</div>
}
</div>
</div>
}
@if (isLoadingAssetClass) {
<div class="loading-container">
<mat-spinner diameter="48"></mat-spinner>
</div>
}
@if (!isLoadingAssetClass && assetClassSummary) {
@if (assetClassSummary.assetClasses.length === 0) {
<div class="empty-state">
<mat-icon>info_outline</mat-icon>
<p i18n>No asset class data available for {{ periodLabel }}.</p>
</div>
} @else {
<div class="table-container">
<table mat-table [dataSource]="assetClassSummary.assetClasses" class="performance-table">
<ng-container matColumnDef="assetClassLabel">
<th mat-header-cell *matHeaderCellDef i18n>Asset Class</th>
<td mat-cell *matCellDef="let row" class="asset-class-name clickable">{{ row.assetClassLabel }}</td>
<td mat-footer-cell *matFooterCellDef class="totals-label"><strong i18n>All Asset Classes</strong></td>
</ng-container>
<ng-container matColumnDef="originalCommitment">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Original Commitment</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.originalCommitment | gfAccountingNumber }}</td>
<td mat-footer-cell *matFooterCellDef class="numeric totals-value"><strong>{{ assetClassSummary.totals.originalCommitment | gfAccountingNumber }}</strong></td>
</ng-container>
<ng-container matColumnDef="percentCalled">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>% Called</th>
<td mat-cell *matCellDef="let row" class="numeric">
@if (row.percentCalled !== null) {
{{ row.percentCalled / 100 | percent:'1.0-0' }}
} @else {
N/A
}
</td>
<td mat-footer-cell *matFooterCellDef class="numeric totals-value">
<strong>
@if (assetClassSummary.totals.percentCalled !== null) {
{{ assetClassSummary.totals.percentCalled / 100 | percent:'1.0-0' }}
} @else {
N/A
}
</strong>
</td>
</ng-container>
<ng-container matColumnDef="unfundedCommitment">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Unfunded</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.unfundedCommitment | gfAccountingNumber }}</td>
<td mat-footer-cell *matFooterCellDef class="numeric totals-value"><strong>{{ assetClassSummary.totals.unfundedCommitment | gfAccountingNumber }}</strong></td>
</ng-container>
<ng-container matColumnDef="paidIn">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Paid-In</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.paidIn | gfAccountingNumber }}</td>
<td mat-footer-cell *matFooterCellDef class="numeric totals-value"><strong>{{ assetClassSummary.totals.paidIn | gfAccountingNumber }}</strong></td>
</ng-container>
<ng-container matColumnDef="distributions">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Distributions</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.distributions | gfAccountingNumber }}</td>
<td mat-footer-cell *matFooterCellDef class="numeric totals-value"><strong>{{ assetClassSummary.totals.distributions | gfAccountingNumber }}</strong></td>
</ng-container>
<ng-container matColumnDef="residualUsed">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Residual Used</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.residualUsed | gfAccountingNumber }}</td>
<td mat-footer-cell *matFooterCellDef class="numeric totals-value"><strong>{{ assetClassSummary.totals.residualUsed | gfAccountingNumber }}</strong></td>
</ng-container>
<ng-container matColumnDef="dpi">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>DPI</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.dpi | number:'1.2-2' }}</td>
<td mat-footer-cell *matFooterCellDef class="numeric totals-value"><strong>{{ assetClassSummary.totals.dpi | number:'1.2-2' }}</strong></td>
</ng-container>
<ng-container matColumnDef="rvpi">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>RVPI</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.rvpi | number:'1.2-2' }}</td>
<td mat-footer-cell *matFooterCellDef class="numeric totals-value"><strong>{{ assetClassSummary.totals.rvpi | number:'1.2-2' }}</strong></td>
</ng-container>
<ng-container matColumnDef="tvpi">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>TVPI</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.tvpi | number:'1.2-2' }}</td>
<td mat-footer-cell *matFooterCellDef class="numeric totals-value"><strong>{{ assetClassSummary.totals.tvpi | number:'1.2-2' }}</strong></td>
</ng-container>
<ng-container matColumnDef="irr">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>IRR</th>
<td mat-cell *matCellDef="let row" class="numeric">
@if (row.irr !== null) {
{{ row.irr | percent:'1.2-2' }}
} @else {
N/A
}
</td>
<td mat-footer-cell *matFooterCellDef class="numeric totals-value">
<strong>
@if (assetClassSummary.totals.irr !== null) {
{{ assetClassSummary.totals.irr | percent:'1.2-2' }}
} @else {
N/A
}
</strong>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="assetClassSummaryColumns"></tr>
<tr mat-row *matRowDef="let row; columns: assetClassSummaryColumns;"
class="clickable-row"
(click)="onAssetClassRowClick(row.assetClass)"></tr>
<tr mat-footer-row *matFooterRowDef="assetClassSummaryColumns" class="totals-row"></tr>
</table>
</div>
}
}
</mat-tab>
<!-- Tab 3: Activity Detail -->
<mat-tab>
<ng-template mat-tab-label>
<mat-icon class="tab-icon">receipt_long</mat-icon>
<span i18n>Activity Detail</span>
</ng-template>
<!-- Activity Filters -->
<div class="activity-filters">
<mat-form-field appearance="outline">
<mat-label i18n>Entity</mat-label>
<mat-select [(ngModel)]="activityEntityFilter" (selectionChange)="onActivityFilterChange()">
<mat-option value="" i18n>All Entities</mat-option>
@if (activityDetail?.filters?.entities) {
@for (entity of activityDetail.filters.entities; track entity.id) {
<mat-option [value]="entity.id">{{ entity.name }}</mat-option>
}
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label i18n>Partnership</mat-label>
<mat-select [(ngModel)]="activityPartnershipFilter" (selectionChange)="onActivityFilterChange()">
<mat-option value="" i18n>All Partnerships</mat-option>
@if (activityDetail?.filters?.partnerships) {
@for (partnership of activityDetail.filters.partnerships; track partnership.id) {
<mat-option [value]="partnership.id">{{ partnership.name }}</mat-option>
}
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label i18n>Year</mat-label>
<mat-select [(ngModel)]="activityYearFilter" (selectionChange)="onActivityFilterChange()">
<mat-option [value]="null" i18n>All Years</mat-option>
@if (activityDetail?.filters?.years) {
@for (year of activityDetail.filters.years; track year) {
<mat-option [value]="year">{{ year }}</mat-option>
}
}
</mat-select>
</mat-form-field>
</div>
@if (isLoadingActivity) {
<div class="loading-container">
<mat-spinner diameter="48"></mat-spinner>
</div>
}
@if (!isLoadingActivity && activityDetail) {
@if (activityDetail.rows.length === 0) {
<div class="empty-state">
<mat-icon>info_outline</mat-icon>
<p i18n>No activity data available for the selected filters.</p>
</div>
} @else {
<div class="table-container activity-table-container">
<table mat-table [dataSource]="activityDetail.rows" class="activity-table">
<ng-container matColumnDef="year">
<th mat-header-cell *matHeaderCellDef i18n>Year</th>
<td mat-cell *matCellDef="let row">{{ row.year }}</td>
</ng-container>
<ng-container matColumnDef="entityName">
<th mat-header-cell *matHeaderCellDef i18n>Entity</th>
<td mat-cell *matCellDef="let row">{{ row.entityName }}</td>
</ng-container>
<ng-container matColumnDef="partnershipName">
<th mat-header-cell *matHeaderCellDef i18n>Partnership</th>
<td mat-cell *matCellDef="let row">{{ row.partnershipName }}</td>
</ng-container>
<ng-container matColumnDef="beginningBasis">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Beg Basis</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.beginningBasis | gfAccountingNumber }}</td>
</ng-container>
<ng-container matColumnDef="contributions">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Contributions</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.contributions | gfAccountingNumber }}</td>
</ng-container>
<ng-container matColumnDef="interest">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Interest</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.interest | gfAccountingNumber }}</td>
</ng-container>
<ng-container matColumnDef="dividends">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Dividends</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.dividends | gfAccountingNumber }}</td>
</ng-container>
<ng-container matColumnDef="capitalGains">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Cap Gains</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.capitalGains | gfAccountingNumber }}</td>
</ng-container>
<ng-container matColumnDef="remainingK1IncomeDed">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Remaining K-1</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.remainingK1IncomeDed | gfAccountingNumber }}</td>
</ng-container>
<ng-container matColumnDef="totalIncome">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Total Income</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.totalIncome | gfAccountingNumber }}</td>
</ng-container>
<ng-container matColumnDef="distributions">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Distributions</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.distributions | gfAccountingNumber }}</td>
</ng-container>
<ng-container matColumnDef="otherAdjustments">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Other Adj</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.otherAdjustments | gfAccountingNumber }}</td>
</ng-container>
<ng-container matColumnDef="endingTaxBasis">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Ending Tax Basis</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.endingTaxBasis | gfAccountingNumber }}</td>
</ng-container>
<ng-container matColumnDef="endingGLBalance">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Ending GL Bal</th>
<td mat-cell *matCellDef="let row" class="numeric">
@if (row.endingGLBalance !== null) {
{{ row.endingGLBalance | gfAccountingNumber }}
} @else {
-
}
</td>
</ng-container>
<ng-container matColumnDef="bookToTaxAdj">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Book-to-Tax Adj</th>
<td mat-cell *matCellDef="let row" class="numeric">
@if (row.bookToTaxAdj !== null) {
{{ row.bookToTaxAdj | gfAccountingNumber }}
} @else {
-
}
</td>
</ng-container>
<ng-container matColumnDef="endingK1CapitalAccount">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>K-1 Capital Acct</th>
<td mat-cell *matCellDef="let row" class="numeric">
@if (row.endingK1CapitalAccount !== null) {
{{ row.endingK1CapitalAccount | gfAccountingNumber }}
} @else {
-
}
</td>
</ng-container>
<ng-container matColumnDef="k1CapitalVsTaxBasisDiff">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>K-1 vs Tax Diff</th>
<td mat-cell *matCellDef="let row" class="numeric">
@if (row.k1CapitalVsTaxBasisDiff !== null) {
{{ row.k1CapitalVsTaxBasisDiff | gfAccountingNumber }}
} @else {
-
}
</td>
</ng-container>
<ng-container matColumnDef="excessDistribution">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Excess Dist</th>
<td mat-cell *matCellDef="let row" class="numeric"
[class.excess-distribution]="row.excessDistribution > 0">
@if (row.excessDistribution > 0) {
<strong>{{ row.excessDistribution | gfAccountingNumber }}</strong>
} @else {
{{ row.excessDistribution | gfAccountingNumber }}
}
</td>
</ng-container>
<ng-container matColumnDef="negativeBasis">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Neg Basis?</th>
<td mat-cell *matCellDef="let row" class="numeric"
[class.negative-basis-flag]="row.negativeBasis">
{{ row.negativeBasis ? 'YES' : '' }}
</td>
</ng-container>
<ng-container matColumnDef="deltaEndingBasis">
<th mat-header-cell *matHeaderCellDef class="numeric" i18n>Δ Ending Basis</th>
<td mat-cell *matCellDef="let row" class="numeric">{{ row.deltaEndingBasis | gfAccountingNumber }}</td>
</ng-container>
<ng-container matColumnDef="notes">
<th mat-header-cell *matHeaderCellDef i18n>Notes</th>
<td mat-cell *matCellDef="let row">{{ row.notes ?? '' }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="activityColumns"></tr>
<tr mat-row *matRowDef="let row; columns: activityColumns;"
[class.negative-basis-row]="row.negativeBasis"></tr>
</table>
</div>
<mat-paginator
[length]="activityDetail.totalCount"
[pageIndex]="activityPageIndex"
[pageSize]="activityPageSize"
[pageSizeOptions]="[25, 50, 100]"
(page)="onActivityPageChange($event)"
showFirstLastButtons>
</mat-paginator>
}
}
</mat-tab>
</mat-tab-group>
</div>

14
apps/client/src/app/pages/portfolio-views/portfolio-views-page.routes.ts

@ -0,0 +1,14 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { Routes } from '@angular/router';
import { PortfolioViewsPageComponent } from './portfolio-views-page.component';
export const routes: Routes = [
{
canActivate: [AuthGuard],
component: PortfolioViewsPageComponent,
path: '',
title: $localize`Portfolio Views`
}
];

401
apps/client/src/app/pages/portfolio-views/portfolio-views-page.scss

@ -0,0 +1,401 @@
:host {
display: block;
}
.container {
max-width: 100%;
padding: 1.5rem;
}
// Page Header & Period Controls
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 1rem;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 500;
}
}
.period-controls {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.period-mode-toggle {
::ng-deep .mat-button-toggle-label-content {
padding: 0 12px;
font-size: 13px;
line-height: 34px;
}
}
.year-filter {
width: 120px;
}
.quarter-filter {
width: 160px;
}
.comparison-toggle {
display: flex;
align-items: center;
padding-left: 0.5rem;
border-left: 1px solid rgba(0, 0, 0, 0.12);
}
// Comparison Bar
.comparison-bar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: rgba(63, 81, 181, 0.04);
border: 1px solid rgba(63, 81, 181, 0.15);
border-radius: 8px;
.compare-icon {
color: rgba(63, 81, 181, 0.7);
font-size: 20px;
height: 20px;
width: 20px;
}
.compare-label {
font-size: 0.875rem;
color: rgba(0, 0, 0, 0.7);
white-space: nowrap;
}
}
// Comparison Delta Panel
.comparison-panel {
margin: 1rem 0;
padding: 1rem;
background: #fafafa;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 8px;
}
.comparison-panel-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
justify-content: center;
mat-icon {
color: rgba(0, 0, 0, 0.38);
font-size: 20px;
height: 20px;
width: 20px;
}
}
.period-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 16px;
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.primary-badge {
background: rgba(63, 81, 181, 0.12);
color: #3f51b5;
}
.comparison-badge {
background: rgba(156, 39, 176, 0.12);
color: #9c27b0;
}
.delta-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.delta-card {
padding: 0.75rem;
background: white;
border-radius: 6px;
border: 1px solid rgba(0, 0, 0, 0.08);
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.delta-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(0, 0, 0, 0.54);
margin-bottom: 0.375rem;
}
.delta-values {
display: flex;
align-items: baseline;
gap: 0.375rem;
font-size: 0.875rem;
margin-bottom: 0.375rem;
font-variant-numeric: tabular-nums;
.delta-primary {
font-weight: 600;
color: rgba(0, 0, 0, 0.87);
}
.delta-vs {
font-size: 0.6875rem;
color: rgba(0, 0, 0, 0.38);
}
.delta-comparison {
color: rgba(0, 0, 0, 0.54);
}
}
.delta-change {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
.delta-icon {
font-size: 16px;
height: 16px;
width: 16px;
}
.delta-pct {
font-size: 0.75rem;
font-weight: 400;
opacity: 0.7;
}
}
}
// Delta color classes
.delta-positive {
&.delta-change,
& .delta-change {
color: #2e7d32;
}
.delta-icon {
color: #2e7d32;
}
}
.delta-negative {
&.delta-change,
& .delta-change {
color: #c62828;
}
.delta-icon {
color: #c62828;
}
}
.delta-neutral {
&.delta-change,
& .delta-change {
color: rgba(0, 0, 0, 0.38);
}
}
// Tab Styles
::ng-deep .mat-mdc-tab-group {
.mat-mdc-tab {
min-width: unset;
padding: 0 24px;
}
.mdc-tab__text-label {
overflow: visible;
text-overflow: unset;
white-space: nowrap;
}
}
.tab-icon {
margin-right: 8px;
font-size: 20px;
height: 20px;
width: 20px;
}
// Loading / Empty
.loading-container {
display: flex;
align-items: center;
justify-content: center;
padding: 3rem;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: rgba(0, 0, 0, 0.54);
mat-icon {
font-size: 48px;
height: 48px;
width: 48px;
margin-bottom: 1rem;
}
p {
font-size: 1rem;
}
}
// Tables
.table-container {
overflow-x: auto;
margin-top: 1rem;
}
.performance-table,
.activity-table {
width: 100%;
.numeric {
text-align: right;
font-variant-numeric: tabular-nums;
}
.clickable,
.entity-name,
.asset-class-name {
cursor: pointer;
font-weight: 500;
}
}
.clickable-row {
cursor: pointer;
&:hover {
background-color: rgba(0, 0, 0, 0.04);
}
}
// Totals row
.totals-row {
background-color: rgba(0, 0, 0, 0.04);
border-top: 2px solid rgba(0, 0, 0, 0.12);
.totals-label {
font-weight: 700;
}
.totals-value {
font-weight: 700;
}
}
// Activity-specific
.activity-filters {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 1rem 0;
mat-form-field {
min-width: 160px;
}
}
.activity-table-container {
overflow-x: auto;
.activity-table {
min-width: 1800px;
}
}
// Flags
.negative-basis-row {
background-color: rgba(244, 67, 54, 0.08);
}
.negative-basis-flag {
color: #f44336;
font-weight: 600;
}
.excess-distribution {
color: #e65100;
font-weight: 600;
}
// Responsive
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
}
.period-controls {
width: 100%;
flex-direction: column;
align-items: flex-start;
}
.comparison-toggle {
border-left: none;
padding-left: 0;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.12);
width: 100%;
}
.comparison-bar {
flex-wrap: wrap;
}
.delta-grid {
grid-template-columns: 1fr 1fr;
}
.activity-filters {
flex-direction: column;
}
}
@media (max-width: 480px) {
.delta-grid {
grid-template-columns: 1fr;
}
}

86
apps/client/src/app/services/family-office-data.service.ts

@ -1,4 +1,6 @@
import {
IActivityDetail,
IAssetClassSummary,
IDistributionListResponse,
IEntity,
IEntityPortfolio,
@ -9,7 +11,8 @@ import {
IPartnership,
IPartnershipDetail,
IPartnershipPerformance,
IPartnershipValuation
IPartnershipValuation,
IPortfolioSummary
} from '@ghostfolio/common/interfaces';
import { HttpClient, HttpParams } from '@angular/common/http';
@ -362,6 +365,87 @@ export class FamilyOfficeDataService {
);
}
public fetchPortfolioSummary(params?: {
quarter?: number;
valuationYear?: number;
}): Observable<IPortfolioSummary> {
let httpParams = new HttpParams();
if (params?.valuationYear) {
httpParams = httpParams.set(
'valuationYear',
params.valuationYear.toString()
);
}
if (params?.quarter) {
httpParams = httpParams.set('quarter', params.quarter.toString());
}
return this.http.get<IPortfolioSummary>(
'/api/v1/family-office/portfolio-summary',
{ params: httpParams }
);
}
public fetchAssetClassSummary(params?: {
quarter?: number;
valuationYear?: number;
}): Observable<IAssetClassSummary> {
let httpParams = new HttpParams();
if (params?.valuationYear) {
httpParams = httpParams.set(
'valuationYear',
params.valuationYear.toString()
);
}
if (params?.quarter) {
httpParams = httpParams.set('quarter', params.quarter.toString());
}
return this.http.get<IAssetClassSummary>(
'/api/v1/family-office/asset-class-summary',
{ params: httpParams }
);
}
public fetchActivity(params?: {
entityId?: string;
partnershipId?: string;
skip?: number;
take?: number;
year?: number;
}): Observable<IActivityDetail> {
let httpParams = new HttpParams();
if (params?.entityId) {
httpParams = httpParams.set('entityId', params.entityId);
}
if (params?.partnershipId) {
httpParams = httpParams.set('partnershipId', params.partnershipId);
}
if (params?.year) {
httpParams = httpParams.set('year', params.year.toString());
}
if (params?.skip != null) {
httpParams = httpParams.set('skip', params.skip.toString());
}
if (params?.take != null) {
httpParams = httpParams.set('take', params.take.toString());
}
return this.http.get<IActivityDetail>(
'/api/v1/family-office/activity',
{ params: httpParams }
);
}
public fetchReport(params: {
period: string;
year: number;

78
docker/docker-compose.railway.yml

@ -0,0 +1,78 @@
# docker-compose.railway.yml
#
# Local simulation of the Railway deployment topology.
# Run: docker compose -f docker/docker-compose.railway.yml --env-file .env.railway up --build
#
# This builds and runs the app container the same way Railway will,
# alongside local Postgres and Redis instances (which on Railway are
# provisioned as separate managed services).
name: ghostfolio_railway
services:
app:
build:
context: ../
dockerfile: Dockerfile
container_name: gf-app
restart: unless-stopped
init: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
env_file:
- ../.env.railway
environment:
- PORT=3333
- NODE_ENV=production
ports:
- "${PORT:-3333}:3333"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'curl -f http://localhost:3333/api/v1/health']
interval: 10s
timeout: 5s
retries: 5
start_period: 40s
postgres:
image: postgres:15-alpine
container_name: gf-postgres-railway
restart: unless-stopped
env_file:
- ../.env.railway
healthcheck:
test: ['CMD-SHELL', 'pg_isready -d "$${POSTGRES_DB}" -U $${POSTGRES_USER}']
interval: 10s
timeout: 5s
retries: 5
ports:
- "${POSTGRES_PORT:-5435}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:alpine
container_name: gf-redis-railway
restart: unless-stopped
env_file:
- ../.env.railway
command:
- /bin/sh
- -c
- redis-server --requirepass "$${REDIS_PASSWORD:?REDIS_PASSWORD variable is not set}"
healthcheck:
test: ['CMD-SHELL', 'redis-cli --pass "$${REDIS_PASSWORD}" ping | grep PONG']
interval: 10s
timeout: 5s
retries: 5
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
postgres_data:

13
docker/entrypoint.sh

@ -1,12 +1,15 @@
#!/bin/sh
set -ex
set -e
echo "Running database migrations"
echo "==> Running database migrations"
npx prisma migrate deploy
echo "Seeding the database"
npx prisma db seed
echo "==> Ensuring schema is fully synced"
npx prisma db push --skip-generate --accept-data-loss 2>/dev/null || echo " (db push skipped — schema already in sync)"
echo "Starting the server"
echo "==> Seeding the database (if applicable)"
npx prisma db seed || echo " (seed skipped or already applied)"
echo "==> Starting the server on port ${PORT:-3333}"
exec node main

24
libs/common/src/lib/helper.ts

@ -34,12 +34,36 @@ import {
locale
} from './config';
import { AssetProfileIdentifier, Benchmark } from './interfaces';
import { FamilyOfficeAssetType } from './enums';
import { BenchmarkTrend, ColorScheme } from './types';
export const DATE_FORMAT = 'yyyy-MM-dd';
export const DATE_FORMAT_MONTHLY = 'MMMM yyyy';
export const DATE_FORMAT_YEARLY = 'yyyy';
export const FAMILY_OFFICE_ASSET_TYPE_LABELS: Record<
FamilyOfficeAssetType,
string
> = {
[FamilyOfficeAssetType.REAL_ESTATE]: 'Real Estate',
[FamilyOfficeAssetType.VENTURE_CAPITAL]: 'Venture Capital',
[FamilyOfficeAssetType.PRIVATE_EQUITY]: 'Private Equity',
[FamilyOfficeAssetType.HEDGE_FUND]: 'Hedge Fund',
[FamilyOfficeAssetType.FIXED_INCOME]: 'Credit',
[FamilyOfficeAssetType.COMMODITY]: 'Natural Resources',
[FamilyOfficeAssetType.OTHER]: 'Other',
[FamilyOfficeAssetType.PUBLIC_EQUITY]: 'Public Equity',
[FamilyOfficeAssetType.ART_COLLECTIBLE]: 'Art & Collectibles',
[FamilyOfficeAssetType.CRYPTOCURRENCY]: 'Cryptocurrency',
[FamilyOfficeAssetType.CASH]: 'Cash'
};
export function getFamilyOfficeAssetTypeLabel(
assetType: FamilyOfficeAssetType
): string {
return FAMILY_OFFICE_ASSET_TYPE_LABELS[assetType] ?? 'Other';
}
export function calculateBenchmarkTrend({
days,
historicalData

87
libs/common/src/lib/interfaces/family-office.interface.ts

@ -80,3 +80,90 @@ export interface IPerformanceMetrics {
dpi: number;
rvpi: number;
}
// --- Portfolio Performance Views interfaces ---
export interface IPerformanceRow {
originalCommitment: number;
percentCalled: number | null;
unfundedCommitment: number;
paidIn: number;
distributions: number;
residualUsed: number;
dpi: number;
rvpi: number;
tvpi: number;
irr: number | null;
}
export interface IEntityPerformanceRow extends IPerformanceRow {
entityId: string;
entityName: string;
}
export interface IPortfolioSummary {
entities: IEntityPerformanceRow[];
quarter?: number;
totals: IPerformanceRow;
valuationYear: number;
}
export interface IAssetClassPerformanceRow extends IPerformanceRow {
assetClass: string;
assetClassLabel: string;
}
export interface IAssetClassSummary {
assetClasses: IAssetClassPerformanceRow[];
quarter?: number;
totals: IPerformanceRow;
valuationYear: number;
}
export interface IActivityRow {
year: number;
entityId: string;
entityName: string;
partnershipId: string;
partnershipName: string;
// Basis & Contributions
beginningBasis: number;
contributions: number;
// Income Components (from K1Data)
interest: number;
dividends: number;
capitalGains: number;
remainingK1IncomeDed: number;
totalIncome: number;
// Outflows
distributions: number;
otherAdjustments: number;
// Balances
endingTaxBasis: number;
endingGLBalance: number | null;
bookToTaxAdj: number | null;
endingK1CapitalAccount: number | null;
k1CapitalVsTaxBasisDiff: number | null;
// Flags
excessDistribution: number;
negativeBasis: boolean;
deltaEndingBasis: number;
// Metadata
notes: string | null;
}
export interface IActivityDetail {
rows: IActivityRow[];
totalCount: number;
filters: {
entities: { id: string; name: string }[];
partnerships: { id: string; name: string }[];
years: number[];
};
}

16
libs/common/src/lib/interfaces/index.ts

@ -29,9 +29,16 @@ import type {
IOwnership
} from './entity.interface';
import type {
IActivityDetail,
IActivityRow,
IAssetClassPerformanceRow,
IAssetClassSummary,
IEntityPerformanceRow,
IFamilyOfficeDashboard,
IFamilyOfficeReport,
IPerformanceMetrics
IPerformanceMetrics,
IPerformanceRow,
IPortfolioSummary
} from './family-office.interface';
import type { FilterGroup } from './filter-group.interface';
import type { Filter } from './filter.interface';
@ -175,6 +182,11 @@ export {
IEntityMembership,
IEntityPortfolio,
IEntityWithRelations,
IActivityDetail,
IActivityRow,
IAssetClassPerformanceRow,
IAssetClassSummary,
IEntityPerformanceRow,
IFamilyOfficeDashboard,
IFamilyOfficeReport,
IKDocument,
@ -187,6 +199,8 @@ export {
IPartnershipPerformance,
IPartnershipValuation,
IPerformanceMetrics,
IPerformanceRow,
IPortfolioSummary,
ExportResponse,
Filter,
FilterGroup,

8
libs/common/src/lib/interfaces/k-document.interface.ts

@ -21,6 +21,14 @@ export interface K1Data {
alternativeMinimumTaxItems: number;
distributionsCash: number;
distributionsProperty: number;
// Tax basis tracking fields (optional for backward compatibility)
beginningTaxBasis?: number;
endingTaxBasis?: number;
endingGLBalance?: number;
k1CapitalAccount?: number;
otherAdjustments?: number;
activityNotes?: string;
}
export interface IKDocumentAllocation {

35
libs/common/src/lib/pipes/accounting-number.pipe.ts

@ -0,0 +1,35 @@
import { Pipe, PipeTransform } from '@angular/core';
/**
* Formats numbers in accounting style:
* - Positive: comma-separated (e.g., 1,000,000)
* - Negative: parenthetical (e.g., (355,885))
* - Zero/null/undefined: dash (-)
*
* Usage: {{ value | gfAccountingNumber }}
* Usage with decimals: {{ value | gfAccountingNumber:2 }}
*/
@Pipe({
name: 'gfAccountingNumber',
standalone: true
})
export class GfAccountingNumberPipe implements PipeTransform {
public transform(
value: number | null | undefined,
decimalPlaces: number = 0
): string {
if (value === null || value === undefined || value === 0) {
return '-';
}
const isNegative = value < 0;
const absValue = Math.abs(value);
const formatted = absValue.toLocaleString('en-US', {
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces
});
return isNegative ? `(${formatted})` : formatted;
}
}

3
libs/common/src/lib/pipes/index.ts

@ -1,3 +1,4 @@
import { GfAccountingNumberPipe } from './accounting-number.pipe';
import { GfSymbolPipe } from './symbol.pipe';
export { GfSymbolPipe };
export { GfAccountingNumberPipe, GfSymbolPipe };

15
libs/ui/src/lib/services/admin.service.ts

@ -46,6 +46,13 @@ export class AdminService {
);
}
public clearFamilyOfficeData() {
return this.http.delete<{ deleted: Record<string, number> }>(
'/api/v1/admin/family-office-data'
);
}
public deleteJob(aId: string) {
return this.http.delete<void>(`/api/v1/admin/queue/job/${aId}`);
}
@ -275,6 +282,14 @@ export class AdminService {
return this.http.get<void>(`/api/v1/admin/demo-user/sync`);
}
public seedFamilyOfficeData() {
return this.http.post<{ created: Record<string, number> }>(
'/api/v1/admin/family-office-data/seed',
{}
);
}
public testMarketData({
dataSource,
scraperConfiguration,

309
prisma/migrations/20260316120000_added_family_office_tables/migration.sql

@ -0,0 +1,309 @@
-- CreateEnum
CREATE TYPE "EntityType" AS ENUM ('INDIVIDUAL', 'TRUST', 'LLC', 'LP', 'CORPORATION', 'FOUNDATION', 'ESTATE');
-- CreateEnum
CREATE TYPE "PartnershipType" AS ENUM ('LP', 'GP', 'LLC', 'JOINT_VENTURE', 'FUND');
-- CreateEnum
CREATE TYPE "DistributionType" AS ENUM ('INCOME', 'RETURN_OF_CAPITAL', 'CAPITAL_GAIN', 'GUARANTEED_PAYMENT', 'DIVIDEND', 'INTEREST');
-- CreateEnum
CREATE TYPE "KDocumentType" AS ENUM ('K1', 'K3');
-- CreateEnum
CREATE TYPE "KDocumentStatus" AS ENUM ('DRAFT', 'ESTIMATED', 'FINAL');
-- CreateEnum
CREATE TYPE "FamilyOfficeAssetType" AS ENUM ('PUBLIC_EQUITY', 'PRIVATE_EQUITY', 'REAL_ESTATE', 'HEDGE_FUND', 'VENTURE_CAPITAL', 'FIXED_INCOME', 'COMMODITY', 'ART_COLLECTIBLE', 'CRYPTOCURRENCY', 'CASH', 'OTHER');
-- CreateEnum
CREATE TYPE "ValuationSource" AS ENUM ('APPRAISAL', 'MARKET', 'MANUAL', 'NAV_STATEMENT', 'FUND_ADMIN');
-- CreateEnum
CREATE TYPE "DocumentType" AS ENUM ('K1', 'K3', 'CAPITAL_CALL', 'DISTRIBUTION_NOTICE', 'NAV_STATEMENT', 'APPRAISAL', 'TAX_RETURN', 'SUBSCRIPTION_AGREEMENT', 'OTHER');
-- CreateTable
CREATE TABLE "Entity" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "EntityType" NOT NULL,
"taxId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Entity_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Partnership" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "PartnershipType" NOT NULL,
"inceptionDate" TIMESTAMP(3) NOT NULL,
"fiscalYearEnd" INTEGER NOT NULL DEFAULT 12,
"currency" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "Partnership_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PartnershipMembership" (
"id" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"partnershipId" TEXT NOT NULL,
"ownershipPercent" DECIMAL(65,30) NOT NULL,
"capitalCommitment" DECIMAL(65,30),
"capitalContributed" DECIMAL(65,30),
"classType" TEXT,
"effectiveDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PartnershipMembership_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Ownership" (
"id" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"accountUserId" TEXT NOT NULL,
"ownershipPercent" DECIMAL(65,30) NOT NULL,
"acquisitionDate" TIMESTAMP(3),
"costBasis" DECIMAL(65,30),
"effectiveDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Ownership_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Distribution" (
"id" TEXT NOT NULL,
"partnershipId" TEXT,
"entityId" TEXT NOT NULL,
"type" "DistributionType" NOT NULL,
"amount" DECIMAL(65,30) NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"currency" TEXT NOT NULL,
"taxWithheld" DECIMAL(65,30) DEFAULT 0,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Distribution_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "KDocument" (
"id" TEXT NOT NULL,
"partnershipId" TEXT NOT NULL,
"type" "KDocumentType" NOT NULL,
"taxYear" INTEGER NOT NULL,
"filingStatus" "KDocumentStatus" NOT NULL DEFAULT 'DRAFT',
"data" JSONB NOT NULL,
"documentFileId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "KDocument_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PartnershipAsset" (
"id" TEXT NOT NULL,
"partnershipId" TEXT NOT NULL,
"assetType" "FamilyOfficeAssetType" NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"acquisitionDate" TIMESTAMP(3),
"acquisitionCost" DECIMAL(65,30),
"currentValue" DECIMAL(65,30),
"currency" TEXT NOT NULL,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PartnershipAsset_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AssetValuation" (
"id" TEXT NOT NULL,
"partnershipAssetId" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"value" DECIMAL(65,30) NOT NULL,
"source" "ValuationSource" NOT NULL,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AssetValuation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PartnershipValuation" (
"id" TEXT NOT NULL,
"partnershipId" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"nav" DECIMAL(65,30) NOT NULL,
"source" "ValuationSource" NOT NULL,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PartnershipValuation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Document" (
"id" TEXT NOT NULL,
"entityId" TEXT,
"partnershipId" TEXT,
"type" "DocumentType" NOT NULL,
"name" TEXT NOT NULL,
"filePath" TEXT NOT NULL,
"fileSize" INTEGER,
"mimeType" TEXT,
"taxYear" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Entity_name_idx" ON "Entity"("name");
-- CreateIndex
CREATE INDEX "Entity_type_idx" ON "Entity"("type");
-- CreateIndex
CREATE INDEX "Entity_userId_idx" ON "Entity"("userId");
-- CreateIndex
CREATE INDEX "Partnership_name_idx" ON "Partnership"("name");
-- CreateIndex
CREATE INDEX "Partnership_type_idx" ON "Partnership"("type");
-- CreateIndex
CREATE INDEX "Partnership_userId_idx" ON "Partnership"("userId");
-- CreateIndex
CREATE INDEX "PartnershipMembership_entityId_idx" ON "PartnershipMembership"("entityId");
-- CreateIndex
CREATE INDEX "PartnershipMembership_partnershipId_idx" ON "PartnershipMembership"("partnershipId");
-- CreateIndex
CREATE UNIQUE INDEX "PartnershipMembership_entityId_partnershipId_effectiveDate_key" ON "PartnershipMembership"("entityId", "partnershipId", "effectiveDate");
-- CreateIndex
CREATE INDEX "Ownership_entityId_idx" ON "Ownership"("entityId");
-- CreateIndex
CREATE INDEX "Ownership_accountId_idx" ON "Ownership"("accountId");
-- CreateIndex
CREATE UNIQUE INDEX "Ownership_entityId_accountId_accountUserId_effectiveDate_key" ON "Ownership"("entityId", "accountId", "accountUserId", "effectiveDate");
-- CreateIndex
CREATE INDEX "Distribution_partnershipId_idx" ON "Distribution"("partnershipId");
-- CreateIndex
CREATE INDEX "Distribution_entityId_idx" ON "Distribution"("entityId");
-- CreateIndex
CREATE INDEX "Distribution_date_idx" ON "Distribution"("date");
-- CreateIndex
CREATE INDEX "KDocument_partnershipId_idx" ON "KDocument"("partnershipId");
-- CreateIndex
CREATE INDEX "KDocument_taxYear_idx" ON "KDocument"("taxYear");
-- CreateIndex
CREATE UNIQUE INDEX "KDocument_partnershipId_type_taxYear_key" ON "KDocument"("partnershipId", "type", "taxYear");
-- CreateIndex
CREATE INDEX "PartnershipAsset_partnershipId_idx" ON "PartnershipAsset"("partnershipId");
-- CreateIndex
CREATE INDEX "PartnershipAsset_assetType_idx" ON "PartnershipAsset"("assetType");
-- CreateIndex
CREATE INDEX "AssetValuation_partnershipAssetId_idx" ON "AssetValuation"("partnershipAssetId");
-- CreateIndex
CREATE INDEX "AssetValuation_date_idx" ON "AssetValuation"("date");
-- CreateIndex
CREATE UNIQUE INDEX "AssetValuation_partnershipAssetId_date_key" ON "AssetValuation"("partnershipAssetId", "date");
-- CreateIndex
CREATE INDEX "PartnershipValuation_partnershipId_idx" ON "PartnershipValuation"("partnershipId");
-- CreateIndex
CREATE INDEX "PartnershipValuation_date_idx" ON "PartnershipValuation"("date");
-- CreateIndex
CREATE UNIQUE INDEX "PartnershipValuation_partnershipId_date_key" ON "PartnershipValuation"("partnershipId", "date");
-- CreateIndex
CREATE INDEX "Document_entityId_idx" ON "Document"("entityId");
-- CreateIndex
CREATE INDEX "Document_partnershipId_idx" ON "Document"("partnershipId");
-- AddForeignKey
ALTER TABLE "Entity" ADD CONSTRAINT "Entity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Partnership" ADD CONSTRAINT "Partnership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PartnershipMembership" ADD CONSTRAINT "PartnershipMembership_entityId_fkey" FOREIGN KEY ("entityId") REFERENCES "Entity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PartnershipMembership" ADD CONSTRAINT "PartnershipMembership_partnershipId_fkey" FOREIGN KEY ("partnershipId") REFERENCES "Partnership"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Ownership" ADD CONSTRAINT "Ownership_entityId_fkey" FOREIGN KEY ("entityId") REFERENCES "Entity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Ownership" ADD CONSTRAINT "Ownership_accountId_accountUserId_fkey" FOREIGN KEY ("accountId", "accountUserId") REFERENCES "Account"("id", "userId") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Distribution" ADD CONSTRAINT "Distribution_partnershipId_fkey" FOREIGN KEY ("partnershipId") REFERENCES "Partnership"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Distribution" ADD CONSTRAINT "Distribution_entityId_fkey" FOREIGN KEY ("entityId") REFERENCES "Entity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KDocument" ADD CONSTRAINT "KDocument_partnershipId_fkey" FOREIGN KEY ("partnershipId") REFERENCES "Partnership"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KDocument" ADD CONSTRAINT "KDocument_documentFileId_fkey" FOREIGN KEY ("documentFileId") REFERENCES "Document"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PartnershipAsset" ADD CONSTRAINT "PartnershipAsset_partnershipId_fkey" FOREIGN KEY ("partnershipId") REFERENCES "Partnership"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AssetValuation" ADD CONSTRAINT "AssetValuation_partnershipAssetId_fkey" FOREIGN KEY ("partnershipAssetId") REFERENCES "PartnershipAsset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PartnershipValuation" ADD CONSTRAINT "PartnershipValuation_partnershipId_fkey" FOREIGN KEY ("partnershipId") REFERENCES "Partnership"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_entityId_fkey" FOREIGN KEY ("entityId") REFERENCES "Entity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_partnershipId_fkey" FOREIGN KEY ("partnershipId") REFERENCES "Partnership"("id") ON DELETE CASCADE ON UPDATE CASCADE;

11
railway.toml

@ -0,0 +1,11 @@
[build]
builder = "DOCKERFILE"
dockerfilePath = "Dockerfile"
watchPatterns = ["apps/**", "libs/**", "prisma/**", "package.json", "package-lock.json"]
[deploy]
startCommand = "/ghostfolio/entrypoint.sh"
healthcheckPath = "/api/v1/health"
healthcheckTimeout = 300
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 3

36
specs/003-portfolio-performance-views/checklists/requirements.md

@ -0,0 +1,36 @@
# Specification Quality Checklist: Portfolio Performance Views
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-16
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass validation. The spec references existing data model entity names (Entity, Partnership, etc.) for clarity but does not prescribe implementation approach.
- The "Computed Metrics" section documents calculation formulas at a business-logic level without specifying technology.
- The Assumptions section documents reasonable defaults for ambiguous areas (asset class mapping, valuation year default, GL reference handling, enum mapping for missing asset classes).

210
specs/003-portfolio-performance-views/contracts/api-contracts.md

@ -0,0 +1,210 @@
# API Contracts: Portfolio Performance Views
**Feature**: 003-portfolio-performance-views
**Date**: 2026-03-16
**Base URL**: `/api/v1/family-office`
## Endpoints
### GET /api/v1/family-office/portfolio-summary
Returns entity-level rollup metrics for the Portfolio Summary view.
**Authentication**: JWT required, `permissions.readEntity` required
**Query Parameters**:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| valuationYear | number | No | Current year | Year-end through which metrics are computed |
**Response** `200 OK`: `IPortfolioSummary`
```typescript
interface IPortfolioSummary {
valuationYear: number;
entities: IEntityPerformanceRow[];
totals: IPerformanceRow;
}
interface IEntityPerformanceRow extends IPerformanceRow {
entityId: string;
entityName: string;
}
interface IPerformanceRow {
originalCommitment: number;
percentCalled: number | null; // null when commitment = 0
unfundedCommitment: number;
paidIn: number;
distributions: number;
residualUsed: number;
dpi: number;
rvpi: number;
tvpi: number;
irr: number | null; // null when XIRR cannot be computed
}
```
**Error Responses**:
- `401 Unauthorized` — Missing or invalid JWT
- `403 Forbidden` — Insufficient permissions
---
### GET /api/v1/family-office/asset-class-summary
Returns asset-class-level rollup metrics for the Asset Class Summary view.
**Authentication**: JWT required, `permissions.readEntity` required
**Query Parameters**:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| valuationYear | number | No | Current year | Year-end through which metrics are computed |
**Response** `200 OK`: `IAssetClassSummary`
```typescript
interface IAssetClassSummary {
valuationYear: number;
assetClasses: IAssetClassPerformanceRow[];
totals: IPerformanceRow;
}
interface IAssetClassPerformanceRow extends IPerformanceRow {
assetClass: string; // FamilyOfficeAssetType enum value
assetClassLabel: string; // Display name (e.g., "Real Estate")
}
```
**Error Responses**:
- `401 Unauthorized` — Missing or invalid JWT
- `403 Forbidden` — Insufficient permissions
---
### GET /api/v1/family-office/activity
Returns detailed activity rows for the Activity Detail view.
**Authentication**: JWT required, `permissions.readEntity` required
**Query Parameters**:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| entityId | string | No | All entities | Filter to a specific entity |
| partnershipId | string | No | All partnerships | Filter to a specific partnership |
| year | number | No | All years | Filter to a specific tax year |
| skip | number | No | 0 | Pagination offset |
| take | number | No | 50 | Pagination limit (max 200) |
**Response** `200 OK`: `IActivityDetail`
```typescript
interface IActivityDetail {
rows: IActivityRow[];
totalCount: number; // For pagination
filters: {
entities: { id: string; name: string }[];
partnerships: { id: string; name: string }[];
years: number[];
};
}
interface IActivityRow {
year: number;
entityId: string;
entityName: string;
partnershipId: string;
partnershipName: string;
// Basis & Contributions
beginningBasis: number;
contributions: number;
// Income Components (from K1Data)
interest: number;
dividends: number;
capitalGains: number;
remainingK1IncomeDed: number;
totalIncome: number; // Computed: sum of above 4
// Outflows
distributions: number;
otherAdjustments: number;
// Balances
endingTaxBasis: number;
endingGLBalance: number | null; // null if not entered
bookToTaxAdj: number | null; // null if GL balance not available
endingK1CapitalAccount: number | null;
k1CapitalVsTaxBasisDiff: number | null;
// Flags
excessDistribution: number;
negativeBasis: boolean;
deltaEndingBasis: number;
// Metadata
notes: string | null;
}
```
**Error Responses**:
- `401 Unauthorized` — Missing or invalid JWT
- `403 Forbidden` — Insufficient permissions
- `400 Bad Request` — Invalid filter parameters
---
## Shared Types
These interfaces are added to `@ghostfolio/common` (`libs/common/src/lib/interfaces/family-office.interface.ts`):
```typescript
// Re-exported from the interfaces barrel
export {
IPortfolioSummary,
IEntityPerformanceRow,
IPerformanceRow,
IAssetClassSummary,
IAssetClassPerformanceRow,
IActivityDetail,
IActivityRow
};
```
## Client Service Methods
Added to `FamilyOfficeDataService` (`apps/client/src/app/services/family-office-data.service.ts`):
```typescript
fetchPortfolioSummary(params?: { valuationYear?: number }): Observable<IPortfolioSummary>
// GET /api/v1/family-office/portfolio-summary
fetchAssetClassSummary(params?: { valuationYear?: number }): Observable<IAssetClassSummary>
// GET /api/v1/family-office/asset-class-summary
fetchActivity(params?: {
entityId?: string;
partnershipId?: string;
year?: number;
skip?: number;
take?: number;
}): Observable<IActivityDetail>
// GET /api/v1/family-office/activity
```
## Number Format Conventions
All monetary values in API responses are raw numbers (no formatting). The client applies formatting:
| Type | Format | Example |
|---|---|---|
| Monetary (positive) | Comma-separated | 1,000,000 |
| Monetary (negative) | Parenthetical | (355,885) |
| Monetary (zero) | Dash | - |
| Ratio (DPI/RVPI/TVPI) | 2 decimal places | 2.00 |
| Percentage (% Called) | Whole percent | 100% |
| IRR | Percentage with 2 decimals | 12.34% |
| IRR (unavailable) | Text | N/A |

229
specs/003-portfolio-performance-views/data-model.md

@ -0,0 +1,229 @@
# Data Model: Portfolio Performance Views
**Feature**: 003-portfolio-performance-views
**Date**: 2026-03-16
## Entity Relationship Overview
```
User ─1:N─► Entity ─1:N─► PartnershipMembership ◄─N:1─ Partnership
│ │
│ ├─1:N─► PartnershipAsset (has assetType)
│ ├─1:N─► PartnershipValuation (has nav, date)
│ └─1:N─► KDocument (has K1Data JSON, taxYear)
└─1:N─► Distribution (has amount, date, type)
```
## Entities
### Entity (existing — no changes)
| Field | Type | Description |
|---|---|---|
| id | String (UUID) | Primary key |
| name | String | Display name (e.g., "Entity #1") |
| type | EntityType enum | INDIVIDUAL, TRUST, LLC, LP, CORPORATION, FOUNDATION, ESTATE |
| taxId | String? | Tax identification number |
| userId | String | Owner user FK |
**Role in feature**: Primary grouping dimension for Portfolio Summary view. Each row = one entity.
### Partnership (existing — no changes)
| Field | Type | Description |
|---|---|---|
| id | String (UUID) | Primary key |
| name | String | Display name (e.g., "Partnership #1") |
| type | PartnershipType enum | LP, GP, LLC, JOINT_VENTURE, FUND |
| inceptionDate | DateTime | Used as date for initial contribution cash flow |
| fiscalYearEnd | String? | Month (e.g., "12" for December) |
| currency | String | Default "USD" |
| userId | String | Owner user FK |
**Role in feature**: Contributes to all three views. Asset class determined by its PartnershipAsset records.
### PartnershipMembership (existing — no changes)
| Field | Type | Description |
|---|---|---|
| id | String (UUID) | Primary key |
| entityId | String | FK → Entity |
| partnershipId | String | FK → Partnership |
| ownershipPercent | Decimal | Ownership percentage (0–100) |
| capitalCommitment | Decimal | Original committed amount |
| capitalContributed | Decimal | Cumulative contributed to date |
| classType | String? | Interest class (e.g., "Class A") |
| effectiveDate | DateTime | Membership start |
| endDate | DateTime? | Null = active |
**Role in feature**: Links entities to partnerships. Provides Original Commitment and capitalized amounts for Portfolio Summary. Unique constraint: `[entityId, partnershipId, effectiveDate]`.
### Distribution (existing — no changes)
| Field | Type | Description |
|---|---|---|
| id | String (UUID) | Primary key |
| partnershipId | String? | FK → Partnership |
| entityId | String | FK → Entity |
| type | DistributionType enum | INCOME, RETURN_OF_CAPITAL, CAPITAL_GAIN, GUARANTEED_PAYMENT, DIVIDEND, INTEREST |
| amount | Decimal | Distribution amount |
| date | DateTime | Distribution date |
| currency | String | Default "USD" |
| taxWithheld | Decimal? | Withheld tax amount |
| notes | String? | Free-text notes |
**Role in feature**: Feeds Distributions column in summaries, cash flow series for XIRR, and per-year distribution totals in Activity view.
### PartnershipAsset (existing — no changes)
| Field | Type | Description |
|---|---|---|
| id | String (UUID) | Primary key |
| partnershipId | String | FK → Partnership |
| assetType | FamilyOfficeAssetType enum | PUBLIC_EQUITY, PRIVATE_EQUITY, REAL_ESTATE, HEDGE_FUND, VENTURE_CAPITAL, FIXED_INCOME, COMMODITY, ART_COLLECTIBLE, CRYPTOCURRENCY, CASH, OTHER |
| name | String | Asset name |
| currentValue | Decimal | Latest value |
**Role in feature**: Determines which asset class a partnership belongs to (majority assetType). Feeds Asset Class Summary grouping.
### PartnershipValuation (existing — no changes)
| Field | Type | Description |
|---|---|---|
| id | String (UUID) | Primary key |
| partnershipId | String | FK → Partnership |
| date | DateTime | Valuation date |
| nav | Decimal | Net asset value |
| source | ValuationSource enum | APPRAISAL, MARKET, MANUAL, NAV_STATEMENT, FUND_ADMIN |
**Role in feature**: Provides "Residual Used" (latest NAV × ownership%) for RVPI and TVPI. Terminal value for XIRR cash flows.
### KDocument (existing — data JSON schema extended)
| Field | Type | Description |
|---|---|---|
| id | String (UUID) | Primary key |
| partnershipId | String | FK → Partnership |
| type | KDocumentType enum | K1, K3 |
| taxYear | Int | Tax year (e.g., 2024) |
| filingStatus | KDocumentStatus enum | DRAFT, ESTIMATED, FINAL |
| data | Json | K1Data or K3Data structured JSON |
| documentFileId | String? | FK → Document |
**Unique constraint**: `[partnershipId, type, taxYear]`
**Role in feature**: Primary data source for Activity view income components and tax basis fields.
## K1Data JSON Schema (extended)
### Existing fields (no changes)
| Field | K-1 Box | Type | Activity Column Mapping |
|---|---|---|---|
| ordinaryIncome | Box 1 | number | Part of "Remaining K-1 Income/Ded." |
| netRentalIncome | Box 2 | number | Part of "Remaining K-1 Income/Ded." |
| otherRentalIncome | Box 3 | number | Part of "Remaining K-1 Income/Ded." |
| guaranteedPayments | Box 4 | number | Part of "Remaining K-1 Income/Ded." |
| interestIncome | Box 5 | number | "Interest" column |
| dividends | Box 6a | number | "Dividends" column |
| qualifiedDividends | Box 6b | number | (sub-detail of dividends) |
| royalties | Box 7 | number | Part of "Remaining K-1 Income/Ded." |
| capitalGainLossShortTerm | Box 8 | number | Part of "Cap Gains" |
| capitalGainLossLongTerm | Box 9a | number | Part of "Cap Gains" |
| unrecaptured1250Gain | Box 9b | number | Part of "Cap Gains" |
| section1231GainLoss | Box 9c | number | Part of "Cap Gains" |
| otherIncome | Box 11 | number | Part of "Remaining K-1 Income/Ded." |
| section179Deduction | Box 12 | number | Part of "Remaining K-1 Income/Ded." (negative) |
| otherDeductions | Box 13 | number | Part of "Remaining K-1 Income/Ded." (negative) |
| selfEmploymentEarnings | Box 14 | number | Part of "Remaining K-1 Income/Ded." |
| foreignTaxesPaid | Box 16 | number | Part of "Remaining K-1 Income/Ded." (negative) |
| alternativeMinimumTaxItems | Box 17 | number | (informational) |
| distributionsCash | Box 19a | number | Part of "Distributions" |
| distributionsProperty | Box 19b | number | Part of "Distributions" |
### New fields (added for tax basis tracking)
| Field | Source | Type | Description |
|---|---|---|---|
| beginningTaxBasis | Prior year ending or manual | number? | Beginning of year tax basis |
| endingTaxBasis | Computed or manual | number? | End of year tax basis |
| endingGLBalance | General ledger | number? | Ending GL balance per books |
| k1CapitalAccount | K-1 Schedule L | number? | Ending K-1 capital account |
| otherAdjustments | K-1 Box 18c etc. | number? | Other basis adjustments |
| activityNotes | Manual entry | string? | Per-year notes (e.g., "AJE Completed") |
All new fields are optional (`?`) to maintain backward compatibility with existing K-1 documents.
## Derived/Computed Fields
These are computed at query time and not stored:
### Summary View Computations (per entity or per asset class)
| Metric | Formula | Notes |
|---|---|---|
| Original Commitment | Σ membership.capitalCommitment | Across active memberships in scope |
| Paid-In (ABS) | Σ membership.capitalContributed | Absolute value of contributions |
| % Called | Paid-In ÷ Original Commitment × 100 | Percentage; "N/A" if commitment = 0 |
| Unfunded Commitment | Original Commitment − Paid-In | Can be 0 |
| Distributions | Σ distribution.amount | All distributions in scope |
| Residual Used | Σ (latestValuation.nav × membership.ownershipPercent / 100) | Per-partnership NAV allocated by ownership |
| DPI | Distributions ÷ Paid-In | Decimal multiple; 0 if Paid-In = 0 |
| RVPI | Residual Used ÷ Paid-In | Decimal multiple; 0 if Paid-In = 0 |
| TVPI | (Distributions + Residual Used) ÷ Paid-In | Decimal multiple; 0 if Paid-In = 0 |
| IRR (XIRR) | Newton-Raphson on merged cash flows | null → "N/A" if < 2 cash flows |
### Activity View Computations (per row)
| Field | Formula |
|---|---|
| Capital Gains | shortTerm + longTerm + unrecaptured1250Gain + section1231GainLoss |
| Remaining K-1 Income/Ded. | ordinaryIncome + netRentalIncome + otherRentalIncome + guaranteedPayments + royalties + otherIncome + selfEmploymentEarnings − section179Deduction − otherDeductions − foreignTaxesPaid |
| Total Income | Interest + Dividends + Capital Gains + Remaining K-1 Income/Ded. |
| Book-to-Tax Adj | endingGLBalance − endingTaxBasis |
| K-1 Capital vs Tax Basis Diff | k1CapitalAccount − endingTaxBasis |
| Excess Distribution | max(0, Distributions − (beginningTaxBasis + Contributions + Total Income + otherAdjustments)) |
| Negative Basis? | endingTaxBasis < 0 "YES" |
| Δ Ending Basis vs Prior Year | endingTaxBasis − beginningTaxBasis |
## State Transitions
### KDocument Filing Status
```
DRAFT → ESTIMATED → FINAL
```
Each transition may update the K1Data values (including the new tax basis fields). The Activity view always shows the latest data regardless of filing status.
## Validation Rules
| Rule | Scope | Description |
|---|---|---|
| capitalCommitment ≥ 0 | PartnershipMembership | Commitment cannot be negative |
| capitalContributed ≥ 0 | PartnershipMembership | Contributed cannot be negative |
| capitalContributed ≤ capitalCommitment | PartnershipMembership | Cannot contribute more than committed |
| ownershipPercent ∈ (0, 100] | PartnershipMembership | Must be positive percentage |
| distribution.amount > 0 | Distribution | Distributions are positive flows |
| taxYear ∈ [1900, current+1] | KDocument | Reasonable year range |
| nav ≥ 0 | PartnershipValuation | NAV cannot be negative |
| Σ ownershipPercent per partnership ≤ 100 | PartnershipMembership | Total ownership cannot exceed 100% |
## FamilyOfficeAssetType → Display Label Mapping
| Enum Value | Display Label |
|---|---|
| REAL_ESTATE | Real Estate |
| VENTURE_CAPITAL | Venture Capital |
| PRIVATE_EQUITY | Private Equity |
| HEDGE_FUND | Hedge Fund |
| FIXED_INCOME | Credit |
| COMMODITY | Natural Resources |
| OTHER | Other |
| PUBLIC_EQUITY | Public Equity |
| ART_COLLECTIBLE | Art & Collectibles |
| CRYPTOCURRENCY | Cryptocurrency |
| CASH | Cash |
Note: "Co-Investment" and "Infrastructure" from the spec map to PRIVATE_EQUITY and OTHER respectively until dedicated enum values are added.

79
specs/003-portfolio-performance-views/plan.md

@ -0,0 +1,79 @@
# Implementation Plan: Portfolio Performance Views
**Branch**: `003-portfolio-performance-views` | **Date**: 2026-03-16 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/003-portfolio-performance-views/spec.md`
## Summary
Add three portfolio performance views — Portfolio Summary (entity rollups), Asset Class Summary, and Activity Detail — as a tabbed page accessible from main navigation. The backend extends the existing `FamilyOfficeService` with new aggregation endpoints; the frontend adds a new page with three tab views using Angular Material tables. Leverages the existing `FamilyOfficePerformanceCalculator` for XIRR/TVPI/DPI/RVPI computations and the existing `GfPerformanceMetricsComponent` display patterns.
## Technical Context
**Language/Version**: TypeScript 5.9.2, Node.js >= 22.18.0
**Primary Dependencies**: Angular 21.1.1, NestJS 11.1.14, Angular Material 21.1.1, Prisma 6.19.0, big.js, date-fns 4.1.0
**Storage**: PostgreSQL via Prisma ORM
**Testing**: Jest 30.2.0, ts-jest, jest-preset-angular
**Target Platform**: Web (NestJS serves Angular build), NX 22.5.3 monorepo
**Project Type**: Full-stack web application (Angular + NestJS)
**Performance Goals**: Summary views < 3s load, Activity filtering < 2s (per SC-001/002/003)
**Constraints**: Accounting-format numbers (parentheses for negatives, comma separators), ratios accurate to 0.01 tolerance
**Scale/Scope**: Family office with 4 entities, ~50+ partnerships, multi-year activity data (~200+ rows)
## Constitution Check
_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._
No constitution file exists at `.specify/memory/constitution.md`. Gate is open — no constraints to evaluate. Will re-check after Phase 1 design if constitution is created.
## Project Structure
### Documentation (this feature)
```text
specs/003-portfolio-performance-views/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
└── tasks.md # Phase 2 output (/speckit.tasks)
```
### Source Code (repository root)
```text
apps/api/src/app/
├── family-office/
│ ├── family-office.controller.ts # MODIFY: add 3 new endpoints
│ ├── family-office.service.ts # MODIFY: add aggregation methods
│ └── family-office.module.ts # MODIFY: if new providers needed
├── portfolio/calculator/family-office/
│ └── performance-calculator.ts # REUSE: existing XIRR/TVPI/DPI/RVPI
apps/client/src/app/
├── pages/
│ └── portfolio-performance/ # NEW: tabbed page
│ ├── portfolio-performance-page.component.ts
│ ├── portfolio-performance-page.html
│ ├── portfolio-performance-page.scss
│ └── portfolio-performance-page.routes.ts
├── services/
│ └── family-office-data.service.ts # MODIFY: add 3 new fetch methods
└── app.routes.ts # MODIFY: add route
libs/common/src/lib/
├── interfaces/
│ └── family-office.interface.ts # MODIFY: add 3 new response interfaces
└── enums/
└── family-office.ts # POTENTIALLY MODIFY: asset type mappings
libs/ui/src/lib/
├── performance-metrics/ # REUSE: existing component
└── (no new UI lib components expected — tables are page-specific)
```
**Structure Decision**: NX monorepo with `apps/api` (NestJS), `apps/client` (Angular), `libs/common` (shared interfaces/types), `libs/ui` (shared components). New page follows existing patterns from `family-dashboard`, `reports`, and `partnership-performance` pages. API extends existing `family-office` module.
## Complexity Tracking
> No constitution violations to track.

152
specs/003-portfolio-performance-views/quickstart.md

@ -0,0 +1,152 @@
# Quickstart: Portfolio Performance Views
**Feature**: 003-portfolio-performance-views
**Date**: 2026-03-16
## Overview
This feature adds three analytical views to the portfolio management application:
1. **Portfolio Summary** — Entity-level rollup of financial performance metrics
2. **Asset Class Summary** — Same metrics grouped by asset class
3. **Activity Detail** — Transaction-level ledger with tax basis tracking
All three views live on a single tabbed page at route `/portfolio-views`.
## Architecture
```
┌─────────────────────────────────┐
│ Angular Client │
│ /portfolio-views │
│ ┌─────┐ ┌───────┐ ┌────────┐ │
│ │Tab 1│ │Tab 2 │ │Tab 3 │ │
│ │Port.│ │Asset │ │Activity│ │
│ │Summ.│ │Class │ │Detail │ │
│ └──┬──┘ └──┬────┘ └──┬─────┘ │
│ │ │ │ │
│ FamilyOfficeDataService │
│ │ │ │ │
└─────┼───────┼──────────┼────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────┐
│ NestJS API │
│ FamilyOfficeController │
│ GET /portfolio-summary │
│ GET /asset-class-summary │
│ GET /activity │
│ │ │
│ FamilyOfficeService │
│ │ │
│ FamilyOfficePerformanceCalc │
│ (existing XIRR/TVPI/DPI/RVPI) │
│ │ │
│ PrismaService → PostgreSQL │
└─────────────────────────────────┘
```
## Key Decisions
| # | Decision | See |
|---|---|---|
| R-001 | Reuse existing XIRR calculator with merged cash flows | [research.md](research.md#r-001) |
| R-002 | Asset class = majority PartnershipAsset.assetType | [research.md](research.md#r-002) |
| R-003 | Extend K1Data JSON with tax basis fields | [research.md](research.md#r-003) |
| R-004 | New `/portfolio-views` page with 3 Material tabs | [research.md](research.md#r-004) |
| R-005 | Custom Angular pipe for accounting number format | [research.md](research.md#r-005) |
| R-006 | Page-level valuation year filter | [research.md](research.md#r-006) |
## Data Flow
### Portfolio Summary / Asset Class Summary
```
1. Client selects valuation year (default: current year)
2. GET /family-office/portfolio-summary?valuationYear=2025
3. Backend:
a. Load all user entities with active memberships
b. For each entity, for each membership:
- Sum capitalCommitment → Original Commitment
- Sum capitalContributed → Paid-In
- Load latest NAV ≤ year-end × ownershipPercent → Residual
- Load distributions ≤ year-end → Distributions
- Build dated cash flows (contributions, distributions, terminal NAV)
c. Per entity: compute DPI, RVPI, TVPI, XIRR from aggregated flows
d. Compute "All Entities" totals row
4. Return IPortfolioSummary
5. Client renders mat-table with accounting format pipe
```
Asset Class Summary follows the same flow but groups by partnership's dominant asset type instead of entity.
### Activity Detail
```
1. Client optionally selects entity, partnership, year filters
2. GET /family-office/activity?entityId=...&year=2024&skip=0&take=50
3. Backend:
a. Find all (entity, partnership, year) combinations from:
- PartnershipMembership (entity-partnership links)
- KDocument (partnership-year income data)
- Distribution (entity-partnership dated amounts)
b. For each combination:
- Pull K1Data fields → income components
- Pull extended fields → tax basis
- Sum distributions for that year
- Compute derived fields (totalIncome, excessDistribution, etc.)
c. Apply filters, paginate
4. Return IActivityDetail with filter options
5. Client renders mat-table with conditional row highlighting
```
## Files to Create/Modify
### New Files
| File | Purpose |
|---|---|
| `apps/client/src/app/pages/portfolio-views/portfolio-views-page.component.ts` | Main tabbed page |
| `apps/client/src/app/pages/portfolio-views/portfolio-views-page.html` | Template with 3 tabs |
| `apps/client/src/app/pages/portfolio-views/portfolio-views-page.scss` | Styles |
| `apps/client/src/app/pages/portfolio-views/portfolio-views-page.routes.ts` | Route config |
| `apps/client/src/app/pipes/accounting-number.pipe.ts` | Custom number format pipe |
### Modified Files
| File | Changes |
|---|---|
| `apps/api/src/app/family-office/family-office.controller.ts` | Add 3 new GET endpoints |
| `apps/api/src/app/family-office/family-office.service.ts` | Add `getPortfolioSummary()`, `getAssetClassSummary()`, `getActivity()` |
| `apps/client/src/app/services/family-office-data.service.ts` | Add 3 new fetch methods |
| `apps/client/src/app/app.routes.ts` | Add `portfolio-views` route |
| `libs/common/src/lib/interfaces/family-office.interface.ts` | Add response interfaces |
| `libs/common/src/lib/interfaces/k-document.interface.ts` | Extend K1Data with tax basis fields |
### Reused (no changes)
| File | What's Reused |
|---|---|
| `apps/api/src/app/portfolio/calculator/family-office/performance-calculator.ts` | XIRR, TVPI, DPI, RVPI computations |
| `libs/ui/src/lib/performance-metrics/` | Metric display patterns (reference for styling) |
## Testing Strategy
| Layer | What | How |
|---|---|---|
| Unit (API) | `getPortfolioSummary()` aggregation logic | Jest: mock Prisma, verify metric calculations match expected values |
| Unit (API) | `getAssetClassSummary()` grouping | Jest: verify partnerships correctly grouped by asset type |
| Unit (API) | `getActivity()` K1Data → Activity row mapping | Jest: verify all derived fields computed correctly |
| Unit (Client) | Accounting number pipe | Jest: verify "(1,000)", "1,000,000", "-" formatting |
| Integration | API endpoints return correct response shapes | Jest: hit endpoints with test data, verify IPortfolioSummary/etc. shapes |
| E2E | Tab navigation and data display | Manual or Playwright: navigate to /portfolio-views, switch tabs, verify data |
## Dependencies on Existing Code
- `FamilyOfficePerformanceCalculator` — Must remain stable (no breaking changes)
- `FamilyOfficeService.getUserPartnerships()` — Existing private method for scoping by user/entity
- `PrismaService` — All database access
- `AuthGuard` — Route protection
- `permissions.readEntity` — Endpoint authorization
- `FamilyOfficeAssetType` enum — Asset class categorization
- `K1Data` interface — Extended but backward-compatible

129
specs/003-portfolio-performance-views/research.md

@ -0,0 +1,129 @@
# Research: Portfolio Performance Views
**Feature**: 003-portfolio-performance-views
**Date**: 2026-03-16
## Decision Log
### R-001: XIRR Aggregation Strategy
**Decision**: Reuse existing `FamilyOfficePerformanceCalculator.computeXIRR()` with concatenated cash flows for entity-level and asset-class-level aggregation.
**Rationale**: The `CashFlow` interface is `{ amount: number, date: Date }` with no partnership identifier — cash flows from multiple partnerships can be freely merged into a single array. The calculator already handles sorting by date and Newton-Raphson iteration. For entity-level XIRR, collect all cash flows across the entity's partnerships (scaled by `ownershipPercent`), add a terminal NAV entry, and pass to `computeXIRR()`. For asset-class-level XIRR, group partnerships by their dominant `FamilyOfficeAssetType` and do the same.
**Alternatives considered**:
- Weighted average of per-partnership IRRs — rejected because IRR is not linearly composable; time-weighting doesn't produce a correct aggregate.
- New calculator class — rejected because the existing one handles the computation correctly with no modification needed.
### R-002: Asset Class Determination for Partnerships
**Decision**: Determine a partnership's asset class from the majority `FamilyOfficeAssetType` of its `PartnershipAsset` records. If no assets exist, default to `OTHER`.
**Rationale**: The `PartnershipAsset.assetType` field is already populated per the existing data model. Most partnerships have a dominant asset type. The spec's asset class categories (Real Estate, Venture Capital, Private Equity, Hedge Fund, Credit, Co-Investment, Infrastructure, Natural Resources, Other) map to `FamilyOfficeAssetType` enum values.
**Mapping from spec categories to existing enum**:
| Spec Category | FamilyOfficeAssetType | Notes |
|---|---|---|
| Real Estate | `REAL_ESTATE` | Direct match |
| Venture Capital | `VENTURE_CAPITAL` | Direct match |
| Private Equity | `PRIVATE_EQUITY` | Direct match |
| Hedge Fund | `HEDGE_FUND` | Direct match |
| Credit | `FIXED_INCOME` | Closest existing match |
| Co-Investment | `PRIVATE_EQUITY` | Co-investments are typically PE deals |
| Infrastructure | `OTHER` | No dedicated enum value; consider adding `INFRASTRUCTURE` |
| Natural Resources | `COMMODITY` | Closest existing match |
| Other | `OTHER` | Direct match |
**Alternatives considered**:
- Adding new enum values (INFRASTRUCTURE, CO_INVESTMENT, CREDIT, NATURAL_RESOURCES) — viable but requires Prisma migration and enum update; can be a Phase 2 enhancement.
- Using `Partnership.type` instead of asset-level classification — rejected because `PartnershipType` (LP, GP, LLC, etc.) is structure-based, not asset-class-based.
### R-003: Tax Basis & Activity Data Architecture
**Decision**: Extend `KDocument.data` JSON schema with additional fields for tax basis tracking, and compute derived fields at query time.
**Rationale**: The existing `K1Data` interface stores K-1 box values (interest, dividends, capital gains, deductions, distributions) but lacks tax basis and GL balance fields. The Activity view needs: Beginning Basis, Ending Tax Basis, Ending GL Balance Per Books, K-1 Capital Account, and Other Adjustments. These are typically manually entered from K-1 Schedule L and the firm's general ledger — they are not derivable from existing data.
**Storage approach**: Extend the `K1Data` interface with optional fields:
- `beginningBasis: number` (prior year ending basis, can be auto-populated from previous year's ending)
- `endingTaxBasis: number` (computed or manually entered)
- `endingGLBalance: number` (from GL, manually entered)
- `k1CapitalAccount: number` (from K-1 Schedule L, manually entered)
- `otherAdjustments: number` (IRS form Box 18c or similar adjustments)
- `notes: string` (e.g., "AJE Completed")
**Fields derived at query time** (no storage needed):
- `totalIncome` = interest + dividends + capitalGains + remainingK1IncomeDed
- `bookToTaxAdj` = endingGLBalance − endingTaxBasis
- `k1CapitalVsTaxBasisDiff` = k1CapitalAccount − endingTaxBasis
- `excessDistribution` = max(0, distributions − (beginningBasis + contributions + totalIncome))
- `negativeBasis` = endingTaxBasis < 0
- `deltaEndingBasis` = endingTaxBasis − beginningBasis (from prior year)
**Alternatives considered**:
- New `TaxBasisRecord` model — rejected to avoid schema migration complexity; the JSON column is already flexible and the data is naturally part of the K-1 document context.
- Pure computation from existing data — rejected because GL balance and K-1 capital account are external inputs not derivable from existing fields.
### R-004: Page Architecture — New Page vs. Repurpose
**Decision**: Create a new `/portfolio-views` page with three Material tabs, accessible from main navigation.
**Rationale**: The existing pages serve different purposes:
- `/home` — Ghostfolio's original portfolio overview (public market holdings)
- `/family-office` — High-level dashboard (AUM, allocations, recent distributions)
- `/reports` — Period-specific reporting with benchmarks
- `/portfolio` — Ghostfolio analysis (allocations, FIRE, X-Ray)
None of these naturally accommodate three wide data tables. The new page provides a dedicated analytical workspace matching the CSV-based views the user needs. Tab-based navigation between the three views keeps them co-located.
**Alternatives considered**:
- Repurpose `/family-office` dashboard — rejected because the dashboard serves a different purpose (at-a-glance summary) and would become cluttered.
- Add tabs to `/reports` — rejected because reports are period/benchmark-focused while these views are standing data tables.
- Repurpose `/home` overview — rejected because `/home` is the original Ghostfolio functionality.
### R-005: Accounting Number Format
**Decision**: Use a shared Angular pipe for accounting-format numbers: comma separators, parentheses for negatives, dashes for zero.
**Rationale**: The CSV attachments show consistent formatting: `"1,000,000"` for positive, `"(355,885)"` for negative, `"-"` for zero. A custom pipe (or extending an existing one) keeps formatting consistent across all three views and is reusable for future features.
**Alternatives considered**:
- Inline formatting in each component — rejected due to duplication.
- Using Angular's built-in `CurrencyPipe` — rejected because it uses minus signs (not parentheses) and doesn't support the dash-for-zero convention.
### R-006: Valuation Year Filter
**Decision**: Implement as a year dropdown at the page level, defaulting to current year. Filters data for Portfolio Summary and Asset Class Summary to valuations/distributions through year-end. Activity view uses year as a row filter.
**Rationale**: The CSV references "Valuation Year from IRR Helper!B2" — this is a single global parameter that controls which year-end data the summaries use. Implementing at the page level means one filter controls all three tabs, matching the spreadsheet behavior.
**Alternatives considered**:
- Per-tab year filters — rejected for complexity and divergence from the spreadsheet model.
- No year filter (always latest) — rejected because the spec explicitly requires it (FR-006).
### R-007: Activity View Data Source
**Decision**: Build activity rows by joining `PartnershipMembership`, `Distribution`, and `KDocument` data per entity-partnership-year combination.
**Rationale**: The Activity CSV has one row per entity-partnership-year with data from three sources:
1. **Contributions** from `PartnershipMembership.capitalContributed` (need year attribution — may need to track per-year contributions)
2. **Income components** from `KDocument.data` (K1Data fields, keyed by `partnershipId + taxYear`)
3. **Distributions** from `Distribution` model (summed per entity-partnership-year)
4. **Tax basis fields** from extended `KDocument.data` (R-003)
The join key is `(entityId, partnershipId, taxYear)` — entities derive from memberships, partnerships from the membership relation, and years from K-documents and distributions.
**Alternatives considered**:
- Separate `ActivityRecord` model — rejected as it would duplicate data already stored across existing models.
- Client-side aggregation — rejected because the data volume and computation complexity are better handled server-side.
### R-008: Contribution Year Attribution
**Decision**: Use Distribution records of type contributions (capital calls) for year-attributed contributions. Fall back to `PartnershipMembership.capitalContributed` as the total when per-year breakdown is unavailable.
**Rationale**: The Activity CSV shows contributions per year (e.g., $1,000,000 in year 1, $131,566 in a later year). `PartnershipMembership.capitalContributed` stores only the cumulative total. The `Distribution` model already supports negative-amount (or capital-call type) records with dates. If per-year data exists, use it; otherwise attribute the full `capitalContributed` to the membership's `effectiveDate` year.
**Alternatives considered**:
- New CapitalCall model — rejected; the Distribution model can represent both inflows and outflows with type differentiation.
- Manually entered per-year breakdown — possible but adds user burden; better to derive from existing dated records.

145
specs/003-portfolio-performance-views/spec.md

@ -0,0 +1,145 @@
# Feature Specification: Portfolio Performance Views
**Feature Branch**: `003-portfolio-performance-views`
**Created**: 2026-03-16
**Status**: Draft
**Input**: User description: "I want to either repurpose the Overview page, or create a new page for the various views that I need to see, I need to see performance at an individual level, I need to see performance at the asset level as well. Here is the 3 CSV views we will need"
## User Scenarios & Testing _(mandatory)_
### User Story 1 — Portfolio Summary View (Priority: P1)
As a family office manager, I want to see a consolidated portfolio summary that rolls up financial performance metrics by entity so I can quickly assess how each entity (trust, individual, LLC, etc.) is performing across all its partnership investments.
**Why this priority**: This is the primary performance view — every user session will start by scanning entity-level totals to identify which entities need attention. Without this, the user has no centralized place to evaluate their portfolio.
**Independent Test**: Can be fully tested by navigating to the Portfolio Summary view, verifying that each entity row shows the correct Original Commitment, % Called, Unfunded Commitment, Paid-In, Distributions, Residual, DPI, RVPI, TVPI, and IRR values, and that an "All Entities" totals row appears at the bottom.
**Acceptance Scenarios**:
1. **Given** the user has entities with partnership memberships and recorded contributions/distributions, **When** the user navigates to the Portfolio Summary view, **Then** a table displays one row per entity with columns: Entity, Original Commitment, % Called, Unfunded Commitment, Paid-In (ABS), Distributions, Residual Used, DPI, RVPI, TVPI, IRR (XIRR).
2. **Given** the user has multiple entities, **When** the table is rendered, **Then** a summary "All Entities" totals row appears at the bottom aggregating all entity rows.
3. **Given** an entity has no partnership activity, **When** the Portfolio Summary loads, **Then** the entity row displays zeros/dashes for all numeric columns.
4. **Given** the user selects a valuation year filter, **When** the view refreshes, **Then** all metrics recalculate based on data through the selected year-end.
5. **Given** the user clicks on an entity row, **When** navigated, **Then** the user is taken to that entity's detail page.
---
### User Story 2 — Asset Class Summary View (Priority: P2)
As a family office manager, I want to see portfolio performance broken down by asset class (Real Estate, Venture Capital, Private Equity, Hedge Fund, Credit, Co-Investment, Infrastructure, Natural Resources, Other) so I can evaluate allocation and returns by investment category.
**Why this priority**: Asset class analysis is the second most common lens for evaluating a portfolio — it complements the entity view by answering "how are we doing in private equity vs. real estate?" rather than "how is Trust A doing?"
**Independent Test**: Can be fully tested by navigating to the Asset Class Summary view and verifying that each asset class row shows the same financial metrics (Original Commitment through IRR), with an "All Asset Classes" totals row at the bottom.
**Acceptance Scenarios**:
1. **Given** partnerships have assigned asset types, **When** the user navigates to the Asset Class Summary view, **Then** a table displays one row per asset class with columns: Asset Class, Original Commitment, % Called, Unfunded Commitment, Paid-In (ABS), Distributions, Residual Used, DPI, RVPI, TVPI, IRR (XIRR).
2. **Given** the user has investments in multiple asset classes, **When** the table renders, **Then** an "All Asset Classes" totals row appears at the bottom aggregating all rows.
3. **Given** an asset class has no investments, **When** the view loads, **Then** the asset class row displays zeros/dashes for all metrics.
4. **Given** the user selects a valuation year filter, **When** the view refreshes, **Then** all metrics recalculate for data through that year-end.
5. **Given** the user clicks on an asset class row, **When** the row is activated, **Then** the view expands or drills down to show the individual partnerships within that asset class.
---
### User Story 3 — Activity Detail View (Priority: P3)
As a family office manager, I want to see a detailed activity ledger showing every financial transaction (contributions, income components, distributions, tax basis changes) across all entities and partnerships so I can reconcile K-1 data, track tax basis, and identify excess distributions or negative basis positions.
**Why this priority**: This is the most detailed analytical view — used for tax reconciliation and compliance. While essential, it is consulted less frequently than the summary views and is primarily used during tax season or during audits.
**Independent Test**: Can be fully tested by navigating to the Activity view and verifying each row shows Year, Entity, Partnership, Beginning Basis, Contributions, Interest, Dividends, Capital Gains, Remaining K-1 Income/Deductions, Total Income, Distributions, Other Adjustments, Ending Tax Basis, Ending GL Balance, Book-to-Tax Adjustment, Ending K-1 Capital Account, K-1 Capital vs Tax Basis Difference, Excess Distribution, Negative Basis flag, Change in Ending Basis, and Notes.
**Acceptance Scenarios**:
1. **Given** the user has K-1 data and activity records for entity-partnership pairs, **When** the user navigates to the Activity view, **Then** a table displays one row per year-entity-partnership combination with the full set of financial columns matching the Activity CSV structure.
2. **Given** the Activity view is loaded, **When** the user filters by Entity, **Then** only activity rows for that entity are displayed.
3. **Given** the Activity view is loaded, **When** the user filters by Partnership, **Then** only activity rows for that partnership are displayed.
4. **Given** the Activity view is loaded, **When** the user filters by Year, **Then** only rows for the selected year are displayed.
5. **Given** a row has a negative ending tax basis, **When** the table renders, **Then** the "Negative Basis?" column displays "YES" and the row is visually highlighted.
6. **Given** a row has an excess distribution value greater than zero, **When** the table renders, **Then** the "Excess Distribution" value is displayed and visually flagged.
7. **Given** a row has a Notes value (e.g., "AJE Completed"), **When** the table renders, **Then** the notes text is visible in the Notes column.
---
### Edge Cases
- What happens when an entity has no partnership memberships? — The Portfolio Summary row shows zeros/dashes for all financial columns.
- What happens when XIRR cannot be calculated (insufficient cash flow data)? — The IRR column displays "N/A" or a dash.
- What happens when a partnership has no asset type assigned? — It falls into the "Other" bucket in the Asset Class Summary.
- What happens when the selected valuation year has no data? — All metrics display zeros/dashes with an informational message.
- How are negative financial values displayed? — Parenthetical notation, e.g., "(355,885)" for negative numbers, matching standard accounting conventions.
- What happens with the very wide Activity table on mobile? — The table is horizontally scrollable.
- What happens when there are hundreds of activity rows? — Pagination or virtual scrolling is provided.
## Requirements _(mandatory)_
### Functional Requirements
- **FR-001**: System MUST provide a Portfolio Summary view that aggregates financial metrics (Original Commitment, % Called, Unfunded Commitment, Paid-In, Distributions, Residual Used, DPI, RVPI, TVPI, IRR) rolled up by entity.
- **FR-002**: System MUST provide an Asset Class Summary view that aggregates the same financial metrics rolled up by asset class (Real Estate, Venture Capital, Private Equity, Hedge Fund, Credit, Co-Investment, Infrastructure, Natural Resources, Other).
- **FR-003**: System MUST provide an Activity Detail view showing per-year, per-entity, per-partnership transaction-level data including: Beginning Basis, Contributions, Interest, Dividends, Capital Gains, Remaining K-1 Income/Deductions, Total Income, Distributions, Other Adjustments, Ending Tax Basis, Ending GL Balance Per Books, Book-to-Tax Adjustment, Ending K-1 Capital Account, K-1 Capital vs Tax Basis Difference, Excess Distribution, Negative Basis flag, Change in Ending Basis, and Notes.
- **FR-004**: Each summary view MUST include an aggregated totals row at the bottom (e.g., "All Entities" or "All Asset Classes").
- **FR-005**: Users MUST be able to filter the Activity view by Entity, Partnership, and Year.
- **FR-006**: Users MUST be able to select a valuation year to control which year-end data the Portfolio Summary and Asset Class Summary metrics are computed through.
- **FR-007**: Monetary values MUST be displayed using standard accounting notation with comma separators and parentheses for negative numbers.
- **FR-008**: Ratio metrics (DPI, RVPI, TVPI) MUST be displayed as decimal multiples (e.g., "2.00").
- **FR-009**: Percentage metrics (% Called) MUST be displayed as percent values (e.g., "100%").
- **FR-010**: IRR (XIRR) values MUST be displayed as percentages when available, or "N/A" when insufficient data exists.
- **FR-011**: The Portfolio Summary view MUST allow the user to navigate to the entity detail page by clicking an entity row.
- **FR-012**: The Asset Class Summary view MUST allow the user to drill down into partnerships within a selected asset class.
- **FR-013**: Activity rows with negative ending tax basis MUST be visually flagged (highlighted and/or displaying "YES" in Negative Basis column).
- **FR-014**: Activity rows with excess distributions MUST be visually flagged.
- **FR-015**: All three views MUST be accessible via navigation in the application.
- **FR-016**: The views MUST be accessible from either a repurposed existing page or a new dedicated page with tab-based or segmented navigation between the three views.
### Key Entities
- **Entity**: The legal person (individual, trust, LLC, etc.) that holds partnership interests. The Portfolio Summary aggregates metrics per entity.
- **Partnership**: The investment vehicle an entity participates in. Partnerships have an asset type that feeds the Asset Class Summary.
- **PartnershipMembership**: The link between an entity and a partnership, recording ownership percentage, capital commitment, and capital contributed.
- **Distribution**: Cash or property distributed from a partnership to an entity, used in Paid-In, Distributions, and DPI/TVPI calculations.
- **KDocument**: K-1 tax data per partnership per year, providing the detailed income components (Interest, Dividends, Capital Gains, Remaining K-1 Income/Deductions) and tax basis figures shown in the Activity view.
- **PartnershipValuation**: Period-end NAV valuations used for "Residual Used" and RVPI calculations.
- **PartnershipAsset**: Individual holdings within a partnership, classified by FamilyOfficeAssetType, used to map partnerships to asset classes.
## Computed Metrics
The following metrics are derived from the stored data and should be computed by the system:
- **Original Commitment**: Sum of `capitalCommitment` from PartnershipMembership records for the entity or asset class.
- **% Called**: Paid-In ÷ Original Commitment, expressed as a percentage.
- **Unfunded Commitment**: Original Commitment − Paid-In.
- **Paid-In (ABS)**: Sum of contributions (absolute value of capital calls/contributions from Distribution or Activity records).
- **Distributions**: Sum of all distribution amounts received by the entity or within the asset class.
- **Residual Used**: Latest NAV from PartnershipValuation allocated by ownership percentage (used as proxy for current unrealized value).
- **DPI (Distributions to Paid-In)**: Distributions ÷ Paid-In.
- **RVPI (Residual Value to Paid-In)**: Residual Used ÷ Paid-In.
- **TVPI (Total Value to Paid-In)**: (Distributions + Residual Used) ÷ Paid-In.
- **IRR (XIRR)**: Internal rate of return calculated using the XIRR method on the time-series of cash flows (contributions as outflows, distributions + residual as inflow).
## Success Criteria _(mandatory)_
### Measurable Outcomes
- **SC-001**: Users can view the Portfolio Summary (entity rollups) within 3 seconds of navigating to the page.
- **SC-002**: Users can view the Asset Class Summary (asset class rollups) within 3 seconds of navigating to the page.
- **SC-003**: Users can filter the Activity Detail view by entity, partnership, or year and see results within 2 seconds.
- **SC-004**: All computed ratios (DPI, RVPI, TVPI) match manual spreadsheet calculations within a 0.01 tolerance.
- **SC-005**: The "All Entities" and "All Asset Classes" totals match the sum of individual rows.
- **SC-006**: 100% of negative basis and excess distribution rows are visually flagged without user intervention.
- **SC-007**: Users can navigate from the Portfolio Summary to an entity detail page in a single click.
- **SC-008**: Users completing quarterly portfolio review tasks can find the information they need from these three views without exporting to a spreadsheet.
## Assumptions
- The valuation year filter defaults to the current calendar year if not explicitly selected.
- Asset class mapping uses the `FamilyOfficeAssetType` enum already defined in the schema; partnerships without an explicit asset type are categorized as "Other".
- The asset class for a partnership is determined by the majority asset type of its `PartnershipAsset` records. If no assets exist, the partnership's own metadata is used; if neither exists, it falls into "Other".
- Accounting parenthetical notation "(1,000)" for negative numbers is the standard display format, matching the provided CSV format.
- The Activity view's "GL #4415 - Class #30" header reference is specific to the user's general ledger coding and does not need to be reproduced literally — the system will display the same data columns in a generalized format.
- IRR (XIRR) computation requires at least two dated cash flows; otherwise "N/A" is displayed.
- The "Key" and "PriorKey" columns from the Activity CSV are internal composite keys for lookups and do not need to be shown to the user as visible columns.
- The three views will be organized as tabs within a single page (either a new "Performance" page or a repurposed existing page) accessible from the main navigation.
- "Co-Investment" is mapped to `PRIVATE_EQUITY` or a new enum value; "Credit" maps to `FIXED_INCOME`; "Infrastructure" and "Natural Resources" map to `OTHER` unless new enum values are added.

215
specs/003-portfolio-performance-views/tasks.md

@ -0,0 +1,215 @@
# Tasks: Portfolio Performance Views
**Input**: Design documents from `/specs/003-portfolio-performance-views/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/api-contracts.md, quickstart.md
**Tests**: Not explicitly requested in the feature specification. Test tasks are omitted.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Shared interfaces, types, pipes, and route scaffolding that all views depend on
- [x] T001 [P] Add IPerformanceRow, IEntityPerformanceRow, IPortfolioSummary interfaces in libs/common/src/lib/interfaces/family-office.interface.ts
- [x] T002 [P] Add IAssetClassPerformanceRow, IAssetClassSummary interfaces in libs/common/src/lib/interfaces/family-office.interface.ts
- [x] T003 [P] Add IActivityRow, IActivityDetail interfaces in libs/common/src/lib/interfaces/family-office.interface.ts
- [x] T004 [P] Extend K1Data interface with beginningTaxBasis, endingTaxBasis, endingGLBalance, k1CapitalAccount, otherAdjustments, activityNotes fields in libs/common/src/lib/interfaces/k-document.interface.ts
- [x] T005 [P] Add FamilyOfficeAssetType-to-display-label mapping utility in libs/common/src/lib/utils/ or libs/common/src/lib/helper.ts
- [x] T006 [P] Create accounting number format pipe (comma separators, parentheses for negatives, dash for zero) in apps/client/src/app/pipes/accounting-number.pipe.ts
- [x] T007 Create portfolio-views page scaffold with route config in apps/client/src/app/pages/portfolio-views/portfolio-views-page.routes.ts
- [x] T008 Register portfolio-views lazy route in apps/client/src/app/app.routes.ts
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Backend service helper methods reused across all three endpoints
**⚠️ CRITICAL**: No user story endpoint work can begin until this phase is complete
- [x] T009 Implement helper method to determine partnership asset class from majority PartnershipAsset.assetType in apps/api/src/app/family-office/family-office.service.ts
- [x] T010 Implement helper method to build entity-level aggregated cash flows (contributions, distributions, terminal NAV) for XIRR in apps/api/src/app/family-office/family-office.service.ts
- [x] T011 Implement helper method to compute IPerformanceRow (Original Commitment, Paid-In, Unfunded, Distributions, Residual, DPI, RVPI, TVPI, IRR) from aggregated membership/distribution/valuation data in apps/api/src/app/family-office/family-office.service.ts
- [x] T012 [P] Implement helper method to map K1Data fields to Activity row income components (capitalGains, remainingK1IncomeDed, totalIncome) in apps/api/src/app/family-office/family-office.service.ts
- [x] T013 [P] Implement helper method to compute Activity row derived fields (bookToTaxAdj, k1CapitalVsTaxBasisDiff, excessDistribution, negativeBasis, deltaEndingBasis) in apps/api/src/app/family-office/family-office.service.ts
**Checkpoint**: Foundation ready — user story implementation can now begin in parallel
---
## Phase 3: User Story 1 — Portfolio Summary View (Priority: P1) 🎯 MVP
**Goal**: Entity-level rollup table showing Original Commitment, % Called, Unfunded, Paid-In, Distributions, Residual, DPI, RVPI, TVPI, IRR per entity with "All Entities" totals row
**Independent Test**: Navigate to /portfolio-views, verify Portfolio Summary tab shows one row per entity with correct metrics and a totals row
### Implementation for User Story 1
- [x] T014 [US1] Add GET /family-office/portfolio-summary endpoint with valuationYear query param and JWT + readEntity guard in apps/api/src/app/family-office/family-office.controller.ts
- [x] T015 [US1] Implement getPortfolioSummary(userId, valuationYear) method in apps/api/src/app/family-office/family-office.service.ts — load entities, active memberships, distributions ≤ year-end, latest valuations ≤ year-end; compute IPerformanceRow per entity; compute totals row; return IPortfolioSummary
- [x] T016 [US1] Add fetchPortfolioSummary(params?) method in apps/client/src/app/services/family-office-data.service.ts
- [x] T017 [US1] Build Portfolio Summary tab in portfolio-views page — mat-table with columns: Entity, Original Commitment, % Called, Unfunded Commitment, Paid-In, Distributions, Residual Used, DPI, RVPI, TVPI, IRR in apps/client/src/app/pages/portfolio-views/portfolio-views-page.component.ts
- [x] T018 [US1] Add valuation year dropdown filter at page level (default: current year) that triggers data reload in apps/client/src/app/pages/portfolio-views/portfolio-views-page.component.ts
- [x] T019 [US1] Add "All Entities" sticky totals row at table bottom with bold styling in apps/client/src/app/pages/portfolio-views/portfolio-views-page.html
- [x] T020 [US1] Apply accounting number pipe to all monetary columns, percent pipe to % Called, decimal pipe to DPI/RVPI/TVPI, percent pipe or "N/A" to IRR in apps/client/src/app/pages/portfolio-views/portfolio-views-page.html
- [x] T021 [US1] Add row click handler to navigate to /entities/:id detail page in apps/client/src/app/pages/portfolio-views/portfolio-views-page.component.ts
- [x] T022 [US1] Style Portfolio Summary table — zero/dash display for empty entities, loading spinner during fetch in apps/client/src/app/pages/portfolio-views/portfolio-views-page.scss
**Checkpoint**: Portfolio Summary tab is fully functional — entity rows display correct metrics, totals row aggregates, entity click navigates to detail page, valuation year filter recalculates metrics
---
## Phase 4: User Story 2 — Asset Class Summary View (Priority: P2)
**Goal**: Asset-class-level rollup table showing the same financial metrics grouped by FamilyOfficeAssetType with "All Asset Classes" totals row
**Independent Test**: Navigate to /portfolio-views, switch to Asset Class Summary tab, verify one row per asset class with correct metrics and totals row
### Implementation for User Story 2
- [x] T023 [US2] Add GET /family-office/asset-class-summary endpoint with valuationYear query param and JWT + readEntity guard in apps/api/src/app/family-office/family-office.controller.ts
- [x] T024 [US2] Implement getAssetClassSummary(userId, valuationYear) method in apps/api/src/app/family-office/family-office.service.ts — load partnerships with assets + memberships + distributions + valuations; determine asset class per partnership via helper (T009); group by asset class; compute IPerformanceRow per class; compute totals row; return IAssetClassSummary
- [x] T025 [US2] Add fetchAssetClassSummary(params?) method in apps/client/src/app/services/family-office-data.service.ts
- [x] T026 [US2] Build Asset Class Summary tab in portfolio-views page — mat-table with columns: Asset Class, Original Commitment, % Called, Unfunded Commitment, Paid-In, Distributions, Residual Used, DPI, RVPI, TVPI, IRR in apps/client/src/app/pages/portfolio-views/portfolio-views-page.component.ts
- [x] T027 [US2] Add "All Asset Classes" sticky totals row with bold styling in apps/client/src/app/pages/portfolio-views/portfolio-views-page.html
- [x] T028 [US2] Apply accounting number pipe and display label mapping (FamilyOfficeAssetType → display name) to all columns in apps/client/src/app/pages/portfolio-views/portfolio-views-page.html
- [x] T029 [US2] Display zeros/dashes for asset classes with no investments in apps/client/src/app/pages/portfolio-views/portfolio-views-page.component.ts
- [x] T030 [US2] Add row click handler to expand or drill down into partnerships within the selected asset class in apps/client/src/app/pages/portfolio-views/portfolio-views-page.component.ts
**Checkpoint**: Asset Class Summary tab is fully functional — asset class rows display correct metrics, unassigned partnerships fall to "Other", totals row aggregates, drill-down shows partnerships
---
## Phase 5: User Story 3 — Activity Detail View (Priority: P3)
**Goal**: Full transaction-level ledger showing per year/entity/partnership K-1 income components, tax basis, distributions, derived flags (negative basis, excess distributions), with entity/partnership/year filtering and pagination
**Independent Test**: Navigate to /portfolio-views, switch to Activity tab, verify rows show all financial columns, filter by entity/partnership/year, check negative basis rows are highlighted
### Implementation for User Story 3
- [x] T031 [US3] Add GET /family-office/activity endpoint with entityId, partnershipId, year, skip, take query params and JWT + readEntity guard in apps/api/src/app/family-office/family-office.controller.ts
- [x] T032 [US3] Implement getActivity(userId, filters) method in apps/api/src/app/family-office/family-office.service.ts — join PartnershipMembership + KDocument + Distribution per (entity, partnership, year); map K1Data to income columns using helper (T012); compute derived fields using helper (T013); apply filters; paginate; return IActivityDetail with filter option lists
- [x] T033 [US3] Add fetchActivity(params?) method in apps/client/src/app/services/family-office-data.service.ts
- [x] T034 [US3] Build Activity Detail tab in portfolio-views page — mat-table with columns: Year, Entity, Partnership, Beg Basis, Contributions, Interest, Dividends, Cap Gains, Remaining K-1 Income/Ded, Total Income, Distributions, Other Adj, Ending Tax Basis, Ending GL Balance, Book-to-Tax Adj, Ending K-1 Capital Account, K-1 Capital vs Tax Basis Diff, Excess Distribution, Negative Basis?, Δ Ending Basis, Notes in apps/client/src/app/pages/portfolio-views/portfolio-views-page.component.ts
- [x] T035 [US3] Add entity dropdown filter populated from IActivityDetail.filters.entities in apps/client/src/app/pages/portfolio-views/portfolio-views-page.html
- [x] T036 [US3] Add partnership dropdown filter populated from IActivityDetail.filters.partnerships in apps/client/src/app/pages/portfolio-views/portfolio-views-page.html
- [x] T037 [US3] Add year dropdown filter populated from IActivityDetail.filters.years in apps/client/src/app/pages/portfolio-views/portfolio-views-page.html
- [x] T038 [US3] Implement pagination (mat-paginator) with skip/take params synced to API in apps/client/src/app/pages/portfolio-views/portfolio-views-page.component.ts
- [x] T039 [US3] Add conditional row highlighting — red/warning background for rows where negativeBasis is true in apps/client/src/app/pages/portfolio-views/portfolio-views-page.scss
- [x] T040 [US3] Add visual flag for excess distribution values > 0 (bold text or icon indicator) in apps/client/src/app/pages/portfolio-views/portfolio-views-page.html
- [x] T041 [US3] Apply accounting number pipe to all monetary columns, display "YES"/"" for negativeBasis boolean in apps/client/src/app/pages/portfolio-views/portfolio-views-page.html
- [x] T042 [US3] Enable horizontal scroll for wide table on smaller viewports in apps/client/src/app/pages/portfolio-views/portfolio-views-page.scss
**Checkpoint**: Activity Detail tab is fully functional — all K-1 columns display, filters work, pagination works, negative basis and excess distribution rows are visually flagged
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Improvements affecting multiple user stories
- [x] T043 [P] Add navigation link to /portfolio-views in the application sidebar/navigation menu
- [x] T044 [P] Add loading spinners for all three tabs during data fetch in apps/client/src/app/pages/portfolio-views/portfolio-views-page.component.ts
- [x] T045 [P] Add empty state messaging when valuation year has no data in apps/client/src/app/pages/portfolio-views/portfolio-views-page.html
- [x] T046 Verify all three tabs share the page-level valuation year filter correctly in apps/client/src/app/pages/portfolio-views/portfolio-views-page.component.ts
- [x] T047 Run quickstart.md validation — verify all data flows work end-to-end
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — can start immediately
- **Foundational (Phase 2)**: Depends on T001–T004 (interfaces); T009–T013 BLOCK all user stories
- **User Stories (Phase 3–5)**: All depend on Foundational phase completion
- User stories can proceed in parallel (if staffed) or sequentially in priority order (P1 → P2 → P3)
- **Polish (Phase 6)**: Depends on all desired user stories being complete
### User Story Dependencies
- **User Story 1 (P1)**: Can start after Phase 2 — no dependencies on other stories
- **User Story 2 (P2)**: Can start after Phase 2 — no dependencies on US1 (shares same page component file but different tab section)
- **User Story 3 (P3)**: Can start after Phase 2 — no dependencies on US1/US2 (different tab, different API endpoint)
### Within Each User Story
- Backend endpoint + service (T014–T015, T023–T024, T031–T032) before client data service method (T016, T025, T033)
- Client data service method before UI implementation (T017+, T026+, T034+)
- Core table rendering before styling, formatting, and interaction (click handlers, filters, pagination)
### Parallel Opportunities
- T001–T006 (Setup) — all marked [P], can run in parallel
- T009–T013 (Foundational) — T012 and T013 can run in parallel with each other; T009–T011 are sequential
- T014 + T023 + T031 (controller endpoints) — in different sections of same file, can be parallelized carefully
- T016 + T025 + T033 (client service methods) — in different sections of same file, can be parallelized carefully
---
## Parallel Example: User Story 1
```text
# Launch interface + pipe work in parallel:
Task T001: "Add IPerformanceRow, IEntityPerformanceRow, IPortfolioSummary interfaces"
Task T006: "Create accounting number format pipe"
# Once interfaces exist, launch backend + client service in parallel:
Task T014: "Add GET /portfolio-summary endpoint" (depends on T001)
Task T015: "Implement getPortfolioSummary service" (depends on T001, T010, T011)
Task T016: "Add fetchPortfolioSummary client method" (depends on T001)
# Once service methods exist, build UI:
Task T017–T022: Sequential UI implementation
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup (T001–T008)
2. Complete Phase 2: Foundational (T009–T013)
3. Complete Phase 3: User Story 1 (T014–T022)
4. **STOP and VALIDATE**: Navigate to /portfolio-views, verify entity rollup table, totals row, year filter, entity click navigation
5. Deploy/demo if ready — user has the Portfolio Summary view working
### Incremental Delivery
1. Complete Setup + Foundational → Foundation ready
2. Add User Story 1 → Test independently → Deploy (MVP: Portfolio Summary)
3. Add User Story 2 → Test independently → Deploy (+ Asset Class Summary)
4. Add User Story 3 → Test independently → Deploy (+ Activity Detail)
5. Polish → Navigation, loading states, empty states
6. Each story adds value without breaking previous stories
### Sequential Single-Developer Strategy
1. T001–T008 (Setup) → commit
2. T009–T013 (Foundational) → commit
3. T014–T022 (Portfolio Summary) → commit + validate
4. T023–T030 (Asset Class Summary) → commit + validate
5. T031–T042 (Activity Detail) → commit + validate
6. T043–T047 (Polish) → commit + validate
---
## Notes
- [P] tasks = different files, no dependencies on concurrent tasks
- [US1/US2/US3] label maps task to specific user story for traceability
- All three tabs live on the same page component — coordinate tab index and shared state (valuation year)
- The accounting number pipe is critical infrastructure — must be correct before UI work begins
- Existing `FamilyOfficePerformanceCalculator` handles XIRR/TVPI/DPI/RVPI — no changes needed
- K1Data JSON extension is backward-compatible (all new fields optional)
- Commit after each task or logical group
- Stop at any checkpoint to validate story independently
Loading…
Cancel
Save