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

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) in header.component.html (desktop L76–107, mobile L338–416)
  • hasPermissionToAccessAdminControl is only true for ADMIN role (USER lacks accessAdminControl permission)
  • 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 @HasPermission decorator
  • 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 requires readMarketDataOfMarkets, 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

  1. Grant accessAdminControl to USER — Rejected: would also expose Admin Control panel, resource management, and other admin-only features
  2. 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 returns AccountsResponse with:
    • totalValueInBaseCurrency — aggregate FMV across all accounts
    • totalBalanceInBaseCurrency — 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()
  • AccountWithValue type already has all fields needed: value, valueInBaseCurrency, balanceInBaseCurrency, allocationInPercentage, platform, activitiesCount
  • Excluded accounts filtered via isExcluded field on Account model

Implementation Approach

  • New Angular page/component at /fmv or /fmv/dashboard route
  • Calls existing GET /api/v1/account endpoint (returns AccountsResponse)
  • 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

  1. New dedicated /api/v1/fmv endpoint — Rejected: existing accounts endpoint already provides all needed data; Simplicity First principle
  2. 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

  • AccountDetailDialogComponent already provides:
    • Holdings tab: GfHoldingsTableComponent filtered by accountId
    • Activities tab: orders/transactions filtered by account
    • Cash Balances tab: AccountBalance history
  • Holdings filtering works via filters: [{ id: accountId, type: 'ACCOUNT' }] passed to getHoldings()
  • PortfolioPosition interface 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 AccountDetailDialogComponent with proper accountId
  • 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

  1. New dedicated drill-down page — Rejected: existing dialog already has all required functionality; avoid component duplication
  2. Full-page account detail — Rejected: dialog pattern is established UX; keep consistent

R4: Plaid SDK Integration — Technology Choice

Rationale

  • Server: plaid npm package (v41.4.0) — official Node.js SDK
    • PlaidApi class with linkTokenCreate(), 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_token on success for server exchange
  • Products: Products.Investments provides holdings and transactions
  • Webhook support: HOLDINGS: DEFAULT_UPDATE and INVESTMENTS_TRANSACTIONS: DEFAULT_UPDATE

Security

  • Access tokens encrypted at rest using AES-256-GCM with PLAID_ENCRYPTION_KEY env var
  • itemId stored as public reference; accessToken only 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

  1. ngx-plaid-link community package — Rejected: community-maintained, may lag behind Plaid updates; @plaid/link-initialize is official
  2. CDN script tag — Rejected: less type safety, harder to test; npm package preferred
  3. 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() exposing addJobToQueue()
    • Rate limiting via limiter: { duration, max } option
  • Two queues already exist:
    • DATA_GATHERING_QUEUE — rate limited 1 job/4sec, 12 attempts with exponential backoff
    • PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE — custom lock duration
  • Cron scheduling via @nestjs/schedule @Cron() decorator in CronService
  • Feature flag pattern: ENABLE_FEATURE_PLAID: bool({ default: false })

Implementation Approach

  • New queue constant: PLAID_SYNC_QUEUE = 'PLAID_SYNC_QUEUE' in libs/common/src/lib/config.ts
  • New module: PlaidSyncModule registering queue, processor, service
  • Processor handles: SYNC_INVESTMENTS job (fetch holdings + transactions from Plaid, create/update Orders + SymbolProfiles)
  • Cron: Daily sync at configurable time, guarded by ENABLE_FEATURE_PLAID flag
  • On-demand: User-triggered refresh via API endpoint → enqueue job
  • Rate limit: respect Plaid's rate limits (100 req/min production)

Alternatives Considered

  1. Direct API calls without queue — Rejected: queue provides retry logic, rate limiting, and non-blocking execution
  2. 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

  1. Add to Environment interface in apps/api/src/services/interfaces/environment.interface.ts
  2. Add to cleanEnv() validation in apps/api/src/services/configuration/configuration.service.ts
  3. 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 GfHomeMarketComponent at /home/markets — does NOT call readMarketDataOfMarkets-gated endpoint
    • Premium GfHomeMarketsPremiumComponent at /home/markets-premium — calls GET /market-data/markets requiring readMarketDataOfMarkets
  • readMarketDataOfMarkets is dynamically granted to Premium subscribers of ANY role (USER or ADMIN)
  • The home page component (home-page.component.ts L80–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

  1. Grant readMarketData to USER — Rejected: readMarketData is admin-level (edit asset profiles); would be over-permissioning
  2. 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') and account.subtype (e.g., '401k', 'brokerage', 'ira', 'checking')
  • Currently, Account model has no accountType field
  • 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

  1. Prisma enum — Rejected: Plaid adds new types over time; enum requires migration for each; string is more flexible
  2. Metadata JSON field — Rejected: harder to query and filter; dedicated field is clearer
  3. Ignore account type — Rejected: useful for filtering and display in FMV dashboard