mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
11 KiB
11 KiB
Research: 009-fmv-plaid-drilldown
Date: 2026-03-22
R1: Admin-Gated Features — Navigation & Permission Analysis
Decision: Move legacy features out of admin gate in header template
Rationale
- All 7 legacy features (Overview, Holdings, Summary, Markets, Watchlist, FIRE, X-Ray) are hidden behind
@if (hasPermissionToAccessAdminControl)inheader.component.html(desktop L76–107, mobile L338–416) hasPermissionToAccessAdminControlis only true for ADMIN role (USER lacksaccessAdminControlpermission)- API endpoints do NOT require admin permission — all portfolio endpoints (
/portfolio/details,/portfolio/holdings,/portfolio/performance,/portfolio/investments,/portfolio/report) use JWT auth only, no@HasPermissiondecorator - Watchlist: USER has
createWatchlistItem,deleteWatchlistItem,readWatchlist— fully functional - FIRE & X-Ray: Use JWT-only portfolio endpoints
- Markets basic view: Does NOT call the
readMarketDataOfMarkets-gated endpoint; only the premium Markets view does (which requiresreadMarketDataOfMarkets, dynamically granted to Premium subscribers of any role)
Implementation Approach
- Extract legacy items from the Admin dropdown and add them as standalone nav items visible to all authenticated users
- No API permission changes needed for Overview, Holdings, Summary, Watchlist, FIRE, X-Ray
- Markets basic view works as-is; premium Markets view requires Premium subscription (existing behavior, not a role gate)
Alternatives Considered
- Grant
accessAdminControlto USER — Rejected: would also expose Admin Control panel, resource management, and other admin-only features - Create a new permission per feature — Rejected: Simplicity First; the features already work for any authenticated user, only the nav is gated
R2: FMV Dashboard — Existing Data Pipeline
Decision: Reuse existing PortfolioService.getAccountsWithAggregations() for FMV data
Rationale
getAccountsWithAggregations()already returnsAccountsResponsewith:totalValueInBaseCurrency— aggregate FMV across all accountstotalBalanceInBaseCurrency— aggregate cash- Per-account:
valueInBaseCurrency,balanceInBaseCurrency,allocationInPercentage,activitiesCount, platform
- Per-account value computed in
getValueOfAccountsAndPlatforms():valueInBaseCurrency = FX(cashBalance) + Σ(quantity × marketPrice)per account - Multi-currency conversion handled by
ExchangeRateDataService.toCurrency() AccountWithValuetype already has all fields needed:value,valueInBaseCurrency,balanceInBaseCurrency,allocationInPercentage,platform,activitiesCount- Excluded accounts filtered via
isExcludedfield on Account model
Implementation Approach
- New Angular page/component at
/fmvor/fmv/dashboardroute - Calls existing
GET /api/v1/accountendpoint (returnsAccountsResponse) - Renders hero total (
totalValueInBaseCurrency) + account cards - Each account card links to existing account-detail-dialog for drill-down
- No new API endpoints needed — existing data is sufficient
Alternatives Considered
- New dedicated
/api/v1/fmvendpoint — Rejected: existing accounts endpoint already provides all needed data; Simplicity First principle - Separate FMV calculation service — Rejected: duplicates
getValueOfAccountsAndPlatforms()logic
R3: Asset Drill-Down — Existing Account Detail Pattern
Decision: Leverage existing account-detail-dialog with enhanced entry point from FMV view
Rationale
AccountDetailDialogComponentalready provides:- Holdings tab:
GfHoldingsTableComponentfiltered byaccountId - Activities tab: orders/transactions filtered by account
- Cash Balances tab:
AccountBalancehistory
- Holdings tab:
- Holdings filtering works via
filters: [{ id: accountId, type: 'ACCOUNT' }]passed togetHoldings() PortfolioPositioninterface includes:quantity,investment(cost basis),valueInBaseCurrency,netPerformance,netPerformancePercent,assetProfile(name, symbol, assetClass)- Clicking a holding row opens position detail with price chart, activity history, dividends, sector/country breakdown
- Sorting already supported in
GfHoldingsTableComponent
Implementation Approach
- FMV account cards open existing
AccountDetailDialogComponentwith properaccountId - No new drill-down components needed — existing dialog has all required tabs
- Ensure holdings table columns show: symbol, name, quantity, cost basis (investment), current value, gain/loss
Alternatives Considered
- New dedicated drill-down page — Rejected: existing dialog already has all required functionality; avoid component duplication
- Full-page account detail — Rejected: dialog pattern is established UX; keep consistent
R4: Plaid SDK Integration — Technology Choice
Decision: Use plaid npm package (v41+) with @plaid/link-initialize for Angular client
Rationale
- Server:
plaidnpm package (v41.4.0) — official Node.js SDKPlaidApiclass withlinkTokenCreate(),itemPublicTokenExchange(),investmentsHoldingsGet(),investmentsTransactionsGet()- Targets API version
2020-09-14 - Environments:
PlaidEnvironments.sandbox(dev) and.production
- Client:
@plaid/link-initialize— official framework-agnostic Plaid Link initializer- Works in Angular without wrappers
create({ token, onSuccess, onExit, onEvent })→{ open, exit }- Returns
public_tokenon success for server exchange
- Products:
Products.Investmentsprovides holdings and transactions - Webhook support:
HOLDINGS: DEFAULT_UPDATEandINVESTMENTS_TRANSACTIONS: DEFAULT_UPDATE
Security
- Access tokens encrypted at rest using AES-256-GCM with
PLAID_ENCRYPTION_KEYenv var itemIdstored as public reference;accessTokenonly used server-side- Never log access tokens
Plaid → Ghostfolio Type Mapping
Plaid type |
AssetClass | Plaid subtype |
AssetSubClass |
|---|---|---|---|
| equity | EQUITY | common stock, preferred equity | STOCK |
| etf | EQUITY | etf | ETF |
| mutual fund | EQUITY | mutual fund | MUTUALFUND |
| fixed income | FIXED_INCOME | bond, municipal bond | BOND |
| cash | LIQUIDITY | cash | CASH |
| cryptocurrency | LIQUIDITY | cryptocurrency | CRYPTOCURRENCY |
| derivative | EQUITY | option, warrant | STOCK |
| loan | FIXED_INCOME | — | BOND |
| other/null | ALTERNATIVE_INVESTMENT | private equity fund, LP unit | PRIVATE_EQUITY |
Alternatives Considered
ngx-plaid-linkcommunity package — Rejected: community-maintained, may lag behind Plaid updates;@plaid/link-initializeis official- CDN script tag — Rejected: less type safety, harder to test; npm package preferred
- Build custom API client — Rejected: official SDK is well-maintained and typed
R5: Plaid Sync Queue — Infrastructure Pattern
Decision: Follow existing BullMQ pattern with new PLAID_SYNC_QUEUE
Rationale
- Existing pattern uses
@nestjs/bull(Bull 4.x):- Queue registered in module:
BullModule.registerQueue({ name: QUEUE_NAME }) - Processor class with
@Processor(QUEUE_NAME)and@Process({ name: JOB_NAME })methods - Service with
@InjectQueue()exposingaddJobToQueue() - Rate limiting via
limiter: { duration, max }option
- Queue registered in module:
- Two queues already exist:
DATA_GATHERING_QUEUE— rate limited 1 job/4sec, 12 attempts with exponential backoffPORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE— custom lock duration
- Cron scheduling via
@nestjs/schedule@Cron()decorator inCronService - Feature flag pattern:
ENABLE_FEATURE_PLAID: bool({ default: false })
Implementation Approach
- New queue constant:
PLAID_SYNC_QUEUE = 'PLAID_SYNC_QUEUE'inlibs/common/src/lib/config.ts - New module:
PlaidSyncModuleregistering queue, processor, service - Processor handles:
SYNC_INVESTMENTSjob (fetch holdings + transactions from Plaid, create/update Orders + SymbolProfiles) - Cron: Daily sync at configurable time, guarded by
ENABLE_FEATURE_PLAIDflag - On-demand: User-triggered refresh via API endpoint → enqueue job
- Rate limit: respect Plaid's rate limits (100 req/min production)
Alternatives Considered
- Direct API calls without queue — Rejected: queue provides retry logic, rate limiting, and non-blocking execution
- Separate microservice — Rejected: massive overkill; Simplicity First; existing monorepo queue pattern works
R6: Environment Variables — Plaid Configuration
Decision: Add 5 new env vars following existing pattern
Variables
| Variable | Type | Default | Purpose |
|---|---|---|---|
PLAID_CLIENT_ID |
string | '' |
Plaid API client ID |
PLAID_SECRET |
string | '' |
Plaid API secret |
PLAID_ENV |
string | 'sandbox' |
Plaid environment (sandbox/production) |
PLAID_ENCRYPTION_KEY |
string | '' |
32-byte hex key for AES-256-GCM encryption of access tokens |
ENABLE_FEATURE_PLAID |
boolean | false |
Feature flag to enable/disable Plaid integration |
Implementation
- Add to
Environmentinterface inapps/api/src/services/interfaces/environment.interface.ts - Add to
cleanEnv()validation inapps/api/src/services/configuration/configuration.service.ts - Access via
this.configurationService.get('PLAID_CLIENT_ID')
R7: Markets Page Permission — USER Role Access
Decision: Markets basic view works for USER; premium Markets remains subscription-gated
Rationale
- Two Markets views exist:
- Basic
GfHomeMarketComponentat/home/markets— does NOT callreadMarketDataOfMarkets-gated endpoint - Premium
GfHomeMarketsPremiumComponentat/home/markets-premium— callsGET /market-data/marketsrequiringreadMarketDataOfMarkets
- Basic
readMarketDataOfMarketsis dynamically granted to Premium subscribers of ANY role (USER or ADMIN)- The home page component (
home-page.component.tsL80–87) already auto-switches between basic and premium based on permission - No permission change needed — the routing already handles the correct view per subscription level
Alternatives Considered
- Grant
readMarketDatato USER — Rejected:readMarketDatais admin-level (edit asset profiles); would be over-permissioning - Remove Markets entirely for USER — Rejected: basic view works and provides value
R8: Account Type Storage
Decision: Add optional accountType string field to Account model
Rationale
- Plaid returns
account.type(e.g., 'investment', 'depository', 'credit', 'loan') andaccount.subtype(e.g., '401k', 'brokerage', 'ira', 'checking') - Currently, Account model has no
accountTypefield - Storing as a simple string field (not an enum) avoids frequent enum migrations as Plaid adds new account types
- Field is optional — manually-created accounts don't need it
Alternatives Considered
- Prisma enum — Rejected: Plaid adds new types over time; enum requires migration for each; string is more flexible
- Metadata JSON field — Rejected: harder to query and filter; dedicated field is clearer
- Ignore account type — Rejected: useful for filtering and display in FMV dashboard