mirror of https://github.com/ghostfolio/ghostfolio
35 changed files with 2830 additions and 67 deletions
@ -0,0 +1,22 @@ |
|||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
|
import { PLAID_SYNC_QUEUE } from '@ghostfolio/common/config'; |
||||
|
|
||||
|
import { BullModule } from '@nestjs/bull'; |
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { PlaidSyncProcessor } from './plaid-sync.processor'; |
||||
|
import { PlaidSyncService } from './plaid-sync.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
exports: [PlaidSyncService], |
||||
|
imports: [ |
||||
|
BullModule.registerQueue({ |
||||
|
name: PLAID_SYNC_QUEUE |
||||
|
}), |
||||
|
ConfigurationModule, |
||||
|
PrismaModule |
||||
|
], |
||||
|
providers: [PlaidSyncProcessor, PlaidSyncService] |
||||
|
}) |
||||
|
export class PlaidSyncModule {} |
||||
@ -0,0 +1,260 @@ |
|||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
import { PLAID_SYNC_QUEUE } from '@ghostfolio/common/config'; |
||||
|
|
||||
|
import { Process, Processor } from '@nestjs/bull'; |
||||
|
import { Logger } from '@nestjs/common'; |
||||
|
import { Prisma } from '@prisma/client'; |
||||
|
import { Job } from 'bull'; |
||||
|
import { |
||||
|
Configuration, |
||||
|
PlaidApi, |
||||
|
PlaidEnvironments |
||||
|
} from 'plaid'; |
||||
|
import * as crypto from 'crypto'; |
||||
|
|
||||
|
@Processor(PLAID_SYNC_QUEUE) |
||||
|
export class PlaidSyncProcessor { |
||||
|
private readonly logger = new Logger(PlaidSyncProcessor.name); |
||||
|
private plaidClient: PlaidApi; |
||||
|
|
||||
|
public constructor( |
||||
|
private readonly configurationService: ConfigurationService, |
||||
|
private readonly prismaService: PrismaService |
||||
|
) { |
||||
|
const plaidEnv = this.configurationService.get('PLAID_ENV') || 'sandbox'; |
||||
|
const configuration = new Configuration({ |
||||
|
basePath: PlaidEnvironments[plaidEnv], |
||||
|
baseOptions: { |
||||
|
headers: { |
||||
|
'PLAID-CLIENT-ID': this.configurationService.get('PLAID_CLIENT_ID'), |
||||
|
'PLAID-SECRET': this.configurationService.get('PLAID_SECRET') |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
this.plaidClient = new PlaidApi(configuration); |
||||
|
} |
||||
|
|
||||
|
@Process('sync-holdings') |
||||
|
public async handleSyncHoldings(job: Job<{ plaidItemId: string }>) { |
||||
|
const { plaidItemId } = job.data; |
||||
|
this.logger.log(`Processing sync for PlaidItem ${plaidItemId}`); |
||||
|
|
||||
|
const plaidItem = await this.prismaService.plaidItem.findUnique({ |
||||
|
include: { accounts: true }, |
||||
|
where: { id: plaidItemId } |
||||
|
}); |
||||
|
|
||||
|
if (!plaidItem) { |
||||
|
this.logger.warn(`PlaidItem ${plaidItemId} not found, skipping`); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const accessToken = this.decryptAccessToken(plaidItem.accessToken); |
||||
|
|
||||
|
try { |
||||
|
// Fetch investment holdings from Plaid
|
||||
|
const holdingsResponse = |
||||
|
await this.plaidClient.investmentsHoldingsGet({ |
||||
|
access_token: accessToken |
||||
|
}); |
||||
|
|
||||
|
const { accounts, holdings, securities } = holdingsResponse.data; |
||||
|
|
||||
|
// Update account balances
|
||||
|
// For investment accounts, set balance to 0 because the value
|
||||
|
// comes from holdings (synced as Orders below). Using balances.current
|
||||
|
// would double-count since it includes investment value.
|
||||
|
const today = new Date(new Date().toISOString().split('T')[0]); |
||||
|
|
||||
|
for (const plaidAccount of accounts) { |
||||
|
const matchingAccount = plaidItem.accounts.find( |
||||
|
(a) => a.plaidAccountId === plaidAccount.account_id |
||||
|
); |
||||
|
|
||||
|
if (matchingAccount) { |
||||
|
const isInvestmentAccount = |
||||
|
matchingAccount.accountType === 'investment'; |
||||
|
const newBalance = isInvestmentAccount |
||||
|
? 0 |
||||
|
: (plaidAccount.balances.current ?? 0); |
||||
|
|
||||
|
await this.prismaService.account.update({ |
||||
|
data: { |
||||
|
balance: newBalance |
||||
|
}, |
||||
|
where: { |
||||
|
id_userId: { |
||||
|
id: matchingAccount.id, |
||||
|
userId: plaidItem.userId |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Create/update AccountBalance record (Ghostfolio uses this table
|
||||
|
// for balance display, not the raw Account.balance field)
|
||||
|
await this.prismaService.accountBalance.upsert({ |
||||
|
create: { |
||||
|
accountId: matchingAccount.id, |
||||
|
date: today, |
||||
|
userId: plaidItem.userId, |
||||
|
value: newBalance |
||||
|
}, |
||||
|
update: { |
||||
|
value: newBalance |
||||
|
}, |
||||
|
where: { |
||||
|
accountId_date: { |
||||
|
accountId: matchingAccount.id, |
||||
|
date: today |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Process holdings: create/update Orders for each holding
|
||||
|
for (const holding of holdings) { |
||||
|
const security = securities.find( |
||||
|
(s) => s.security_id === holding.security_id |
||||
|
); |
||||
|
|
||||
|
if (!security || !security.ticker_symbol) { |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
const matchingAccount = plaidItem.accounts.find( |
||||
|
(a) => a.plaidAccountId === holding.account_id |
||||
|
); |
||||
|
|
||||
|
if (!matchingAccount) { |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
// Find or create SymbolProfile
|
||||
|
let symbolProfile = await this.prismaService.symbolProfile.findFirst({ |
||||
|
where: { |
||||
|
symbol: security.ticker_symbol |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
if (!symbolProfile) { |
||||
|
symbolProfile = await this.prismaService.symbolProfile.create({ |
||||
|
data: { |
||||
|
currency: security.iso_currency_code ?? 'USD', |
||||
|
dataSource: 'MANUAL', |
||||
|
name: security.name ?? security.ticker_symbol, |
||||
|
symbol: security.ticker_symbol |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Check if we already have a plaid-synced order for this holding
|
||||
|
const existingOrder = await this.prismaService.order.findFirst({ |
||||
|
where: { |
||||
|
accountId: matchingAccount.id, |
||||
|
comment: { |
||||
|
startsWith: `plaid-sync:${holding.security_id}` |
||||
|
}, |
||||
|
userId: plaidItem.userId |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
if (existingOrder) { |
||||
|
// Update existing order with latest quantity/price
|
||||
|
await this.prismaService.order.update({ |
||||
|
data: { |
||||
|
quantity: holding.quantity, |
||||
|
unitPrice: holding.cost_basis |
||||
|
? holding.cost_basis / holding.quantity |
||||
|
: holding.institution_price ?? 0 |
||||
|
}, |
||||
|
where: { id: existingOrder.id } |
||||
|
}); |
||||
|
} else { |
||||
|
// Create new order
|
||||
|
await this.prismaService.order.create({ |
||||
|
data: { |
||||
|
accountId: matchingAccount.id, |
||||
|
accountUserId: plaidItem.userId, |
||||
|
comment: `plaid-sync:${holding.security_id}`, |
||||
|
currency: security.iso_currency_code ?? 'USD', |
||||
|
date: new Date(), |
||||
|
fee: 0, |
||||
|
isDraft: false, |
||||
|
quantity: holding.quantity, |
||||
|
symbolProfileId: symbolProfile.id, |
||||
|
type: 'BUY', |
||||
|
unitPrice: holding.cost_basis |
||||
|
? holding.cost_basis / holding.quantity |
||||
|
: holding.institution_price ?? 0, |
||||
|
userId: plaidItem.userId |
||||
|
} as Prisma.OrderUncheckedCreateInput |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Store current market price in MarketData
|
||||
|
if (holding.institution_price) { |
||||
|
await this.prismaService.marketData.upsert({ |
||||
|
create: { |
||||
|
dataSource: symbolProfile.dataSource, |
||||
|
date: new Date(new Date().toISOString().split('T')[0]), |
||||
|
marketPrice: holding.institution_price, |
||||
|
symbol: security.ticker_symbol |
||||
|
}, |
||||
|
update: { |
||||
|
marketPrice: holding.institution_price |
||||
|
}, |
||||
|
where: { |
||||
|
dataSource_date_symbol: { |
||||
|
dataSource: symbolProfile.dataSource, |
||||
|
date: new Date(new Date().toISOString().split('T')[0]), |
||||
|
symbol: security.ticker_symbol |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Update lastSyncedAt and clear error
|
||||
|
await this.prismaService.plaidItem.update({ |
||||
|
data: { |
||||
|
error: null, |
||||
|
lastSyncedAt: new Date() |
||||
|
}, |
||||
|
where: { id: plaidItemId } |
||||
|
}); |
||||
|
|
||||
|
this.logger.log( |
||||
|
`Sync complete for PlaidItem ${plaidItemId}: ${holdings.length} holdings processed` |
||||
|
); |
||||
|
} catch (error) { |
||||
|
this.logger.error( |
||||
|
`Sync failed for PlaidItem ${plaidItemId}: ${error.message}` |
||||
|
); |
||||
|
|
||||
|
// Update error status
|
||||
|
await this.prismaService.plaidItem.update({ |
||||
|
data: { |
||||
|
error: error.response?.data?.error_code ?? 'SYNC_FAILED' |
||||
|
}, |
||||
|
where: { id: plaidItemId } |
||||
|
}); |
||||
|
|
||||
|
throw error; // Rethrow so Bull retries
|
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private decryptAccessToken(encryptedToken: string): string { |
||||
|
const keyStr = this.configurationService.get('PLAID_ENCRYPTION_KEY'); |
||||
|
const key = Buffer.from(keyStr.slice(0, 32), 'utf8'); |
||||
|
const [ivHex, authTagHex, encryptedHex] = encryptedToken.split(':'); |
||||
|
const iv = Buffer.from(ivHex, 'hex'); |
||||
|
const authTag = Buffer.from(authTagHex, 'hex'); |
||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); |
||||
|
decipher.setAuthTag(authTag); |
||||
|
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8'); |
||||
|
decrypted += decipher.final('utf8'); |
||||
|
return decrypted; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,58 @@ |
|||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
import { PLAID_SYNC_QUEUE } from '@ghostfolio/common/config'; |
||||
|
|
||||
|
import { InjectQueue } from '@nestjs/bull'; |
||||
|
import { Injectable, Logger } from '@nestjs/common'; |
||||
|
import { Cron, CronExpression } from '@nestjs/schedule'; |
||||
|
import { Queue } from 'bull'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class PlaidSyncService { |
||||
|
private readonly logger = new Logger(PlaidSyncService.name); |
||||
|
|
||||
|
public constructor( |
||||
|
private readonly configurationService: ConfigurationService, |
||||
|
private readonly prismaService: PrismaService, |
||||
|
@InjectQueue(PLAID_SYNC_QUEUE) private readonly syncQueue: Queue |
||||
|
) {} |
||||
|
|
||||
|
public async enqueueSyncJob(plaidItemId: string): Promise<string> { |
||||
|
const job = await this.syncQueue.add( |
||||
|
'sync-holdings', |
||||
|
{ plaidItemId }, |
||||
|
{ |
||||
|
attempts: 3, |
||||
|
backoff: { |
||||
|
delay: 60000, |
||||
|
type: 'exponential' |
||||
|
}, |
||||
|
removeOnComplete: true, |
||||
|
removeOnFail: false |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
this.logger.log(`Enqueued sync job ${job.id} for PlaidItem ${plaidItemId}`); |
||||
|
return String(job.id); |
||||
|
} |
||||
|
|
||||
|
@Cron(CronExpression.EVERY_DAY_AT_6AM) |
||||
|
public async handleDailySync() { |
||||
|
if (!this.configurationService.get('ENABLE_FEATURE_PLAID')) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.logger.log('Starting daily Plaid sync for all items'); |
||||
|
|
||||
|
const plaidItems = await this.prismaService.plaidItem.findMany({ |
||||
|
select: { id: true }, |
||||
|
where: { error: null } |
||||
|
}); |
||||
|
|
||||
|
for (const item of plaidItems) { |
||||
|
await this.enqueueSyncJob(item.id); |
||||
|
} |
||||
|
|
||||
|
this.logger.log(`Enqueued ${plaidItems.length} sync jobs`); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,93 @@ |
|||||
|
import type { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { |
||||
|
Body, |
||||
|
Controller, |
||||
|
Delete, |
||||
|
Get, |
||||
|
Inject, |
||||
|
Param, |
||||
|
Post, |
||||
|
UseGuards |
||||
|
} from '@nestjs/common'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
import { REQUEST } from '@nestjs/core'; |
||||
|
|
||||
|
import { PlaidService } from './plaid.service'; |
||||
|
|
||||
|
@Controller('plaid') |
||||
|
export class PlaidController { |
||||
|
public constructor( |
||||
|
private readonly plaidService: PlaidService, |
||||
|
@Inject(REQUEST) private readonly request: RequestWithUser |
||||
|
) {} |
||||
|
|
||||
|
@Post('link-token') |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async createLinkToken() { |
||||
|
return this.plaidService.createLinkToken(this.request.user.id); |
||||
|
} |
||||
|
|
||||
|
@Post('link-token/update/:plaidItemId') |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async createUpdateLinkToken( |
||||
|
@Param('plaidItemId') plaidItemId: string |
||||
|
) { |
||||
|
return this.plaidService.createUpdateLinkToken( |
||||
|
this.request.user.id, |
||||
|
plaidItemId |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
@Post('exchange-token') |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async exchangeToken( |
||||
|
@Body() |
||||
|
body: { |
||||
|
publicToken: string; |
||||
|
institutionId: string; |
||||
|
institutionName: string; |
||||
|
accounts: { |
||||
|
id: string; |
||||
|
name: string; |
||||
|
type: string; |
||||
|
subtype: string; |
||||
|
mask: string; |
||||
|
}[]; |
||||
|
} |
||||
|
) { |
||||
|
return this.plaidService.exchangeToken(this.request.user.id, body); |
||||
|
} |
||||
|
|
||||
|
@Get('items') |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async getItems() { |
||||
|
return this.plaidService.getItems(this.request.user.id); |
||||
|
} |
||||
|
|
||||
|
@Delete('items/:plaidItemId') |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async deleteItem(@Param('plaidItemId') plaidItemId: string) { |
||||
|
return this.plaidService.deleteItem(this.request.user.id, plaidItemId); |
||||
|
} |
||||
|
|
||||
|
@Post('sync/:plaidItemId') |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async triggerSync(@Param('plaidItemId') plaidItemId: string) { |
||||
|
return this.plaidService.triggerSync(this.request.user.id, plaidItemId); |
||||
|
} |
||||
|
|
||||
|
@Post('webhook') |
||||
|
public async handleWebhook( |
||||
|
@Body() |
||||
|
payload: { |
||||
|
webhook_type: string; |
||||
|
webhook_code: string; |
||||
|
item_id: string; |
||||
|
error?: object; |
||||
|
} |
||||
|
) { |
||||
|
await this.plaidService.handleWebhook(payload); |
||||
|
return {}; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
import { PlaidSyncModule } from '@ghostfolio/api/app/plaid-sync/plaid-sync.module'; |
||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
|
|
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { PlaidController } from './plaid.controller'; |
||||
|
import { PlaidService } from './plaid.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [PlaidController], |
||||
|
exports: [PlaidService], |
||||
|
imports: [ConfigurationModule, PlaidSyncModule, PrismaModule], |
||||
|
providers: [PlaidService] |
||||
|
}) |
||||
|
export class PlaidModule {} |
||||
@ -0,0 +1,397 @@ |
|||||
|
import { PlaidSyncService } from '@ghostfolio/api/app/plaid-sync/plaid-sync.service'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
|
||||
|
import { |
||||
|
ForbiddenException, |
||||
|
Injectable, |
||||
|
Logger, |
||||
|
NotFoundException |
||||
|
} from '@nestjs/common'; |
||||
|
import { |
||||
|
Configuration, |
||||
|
CountryCode, |
||||
|
PlaidApi, |
||||
|
PlaidEnvironments, |
||||
|
Products |
||||
|
} from 'plaid'; |
||||
|
import * as crypto from 'crypto'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class PlaidService { |
||||
|
private readonly logger = new Logger(PlaidService.name); |
||||
|
private plaidClient: PlaidApi; |
||||
|
|
||||
|
public constructor( |
||||
|
private readonly configurationService: ConfigurationService, |
||||
|
private readonly plaidSyncService: PlaidSyncService, |
||||
|
private readonly prismaService: PrismaService |
||||
|
) { |
||||
|
const plaidEnv = this.configurationService.get('PLAID_ENV') || 'sandbox'; |
||||
|
const configuration = new Configuration({ |
||||
|
basePath: PlaidEnvironments[plaidEnv], |
||||
|
baseOptions: { |
||||
|
headers: { |
||||
|
'PLAID-CLIENT-ID': this.configurationService.get('PLAID_CLIENT_ID'), |
||||
|
'PLAID-SECRET': this.configurationService.get('PLAID_SECRET') |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
this.plaidClient = new PlaidApi(configuration); |
||||
|
} |
||||
|
|
||||
|
public isEnabled(): boolean { |
||||
|
return this.configurationService.get('ENABLE_FEATURE_PLAID') === true; |
||||
|
} |
||||
|
|
||||
|
public async createLinkToken(userId: string): Promise<{ |
||||
|
linkToken: string; |
||||
|
expiration: string; |
||||
|
}> { |
||||
|
this.ensureEnabled(); |
||||
|
|
||||
|
const response = await this.plaidClient.linkTokenCreate({ |
||||
|
client_name: 'Ghostfolio', |
||||
|
country_codes: [CountryCode.Us], |
||||
|
language: 'en', |
||||
|
products: [Products.Investments], |
||||
|
user: { |
||||
|
client_user_id: userId |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
return { |
||||
|
linkToken: response.data.link_token, |
||||
|
expiration: response.data.expiration |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public async createUpdateLinkToken( |
||||
|
userId: string, |
||||
|
plaidItemId: string |
||||
|
): Promise<{ |
||||
|
linkToken: string; |
||||
|
expiration: string; |
||||
|
}> { |
||||
|
this.ensureEnabled(); |
||||
|
|
||||
|
const plaidItem = await this.prismaService.plaidItem.findUnique({ |
||||
|
where: { id: plaidItemId } |
||||
|
}); |
||||
|
|
||||
|
if (!plaidItem) { |
||||
|
throw new NotFoundException('PlaidItem not found'); |
||||
|
} |
||||
|
|
||||
|
if (plaidItem.userId !== userId) { |
||||
|
throw new ForbiddenException('Not owner of PlaidItem'); |
||||
|
} |
||||
|
|
||||
|
const decryptedToken = this.decryptAccessToken(plaidItem.accessToken); |
||||
|
|
||||
|
const response = await this.plaidClient.linkTokenCreate({ |
||||
|
access_token: decryptedToken, |
||||
|
client_name: 'Ghostfolio', |
||||
|
country_codes: [CountryCode.Us], |
||||
|
language: 'en', |
||||
|
user: { |
||||
|
client_user_id: userId |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
return { |
||||
|
linkToken: response.data.link_token, |
||||
|
expiration: response.data.expiration |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public async exchangeToken( |
||||
|
userId: string, |
||||
|
{ |
||||
|
publicToken, |
||||
|
institutionId, |
||||
|
institutionName, |
||||
|
accounts |
||||
|
}: { |
||||
|
publicToken: string; |
||||
|
institutionId: string; |
||||
|
institutionName: string; |
||||
|
accounts: { |
||||
|
id: string; |
||||
|
name: string; |
||||
|
type: string; |
||||
|
subtype: string; |
||||
|
mask: string; |
||||
|
}[]; |
||||
|
} |
||||
|
) { |
||||
|
this.ensureEnabled(); |
||||
|
|
||||
|
// Exchange public token for access token
|
||||
|
const exchangeResponse = |
||||
|
await this.plaidClient.itemPublicTokenExchange({ |
||||
|
public_token: publicToken |
||||
|
}); |
||||
|
|
||||
|
const accessToken = exchangeResponse.data.access_token; |
||||
|
const itemId = exchangeResponse.data.item_id; |
||||
|
|
||||
|
// Encrypt the access token for storage
|
||||
|
const encryptedToken = this.encryptAccessToken(accessToken); |
||||
|
|
||||
|
// Find or create platform for the institution
|
||||
|
let platform = await this.prismaService.platform.findFirst({ |
||||
|
where: { name: institutionName } |
||||
|
}); |
||||
|
|
||||
|
if (!platform) { |
||||
|
platform = await this.prismaService.platform.create({ |
||||
|
data: { |
||||
|
name: institutionName, |
||||
|
url: '' |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Create PlaidItem
|
||||
|
const plaidItem = await this.prismaService.plaidItem.create({ |
||||
|
data: { |
||||
|
accessToken: encryptedToken, |
||||
|
institutionId, |
||||
|
institutionName, |
||||
|
itemId, |
||||
|
userId |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Create accounts for each Plaid account
|
||||
|
const createdAccounts = []; |
||||
|
for (const acct of accounts) { |
||||
|
const account = await this.prismaService.account.create({ |
||||
|
data: { |
||||
|
accountType: acct.type, |
||||
|
currency: 'USD', |
||||
|
name: `${acct.name} (${acct.mask})`, |
||||
|
plaidAccountId: acct.id, |
||||
|
plaidItemId: plaidItem.id, |
||||
|
platformId: platform.id, |
||||
|
userId |
||||
|
} |
||||
|
}); |
||||
|
createdAccounts.push({ |
||||
|
accountId: account.id, |
||||
|
name: account.name, |
||||
|
plaidAccountId: acct.id |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Auto-trigger sync to pull balances and holdings
|
||||
|
try { |
||||
|
await this.plaidSyncService.enqueueSyncJob(plaidItem.id); |
||||
|
this.logger.log( |
||||
|
`Auto-enqueued sync job for newly linked PlaidItem ${plaidItem.id}` |
||||
|
); |
||||
|
} catch (error) { |
||||
|
this.logger.warn( |
||||
|
`Failed to auto-enqueue sync for PlaidItem ${plaidItem.id}: ${error.message}` |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
accounts: createdAccounts, |
||||
|
plaidItemId: plaidItem.id |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public async getItems(userId: string) { |
||||
|
if (!this.isEnabled()) { |
||||
|
return { enabled: false, items: [] }; |
||||
|
} |
||||
|
|
||||
|
const items = await this.prismaService.plaidItem.findMany({ |
||||
|
include: { |
||||
|
_count: { |
||||
|
select: { accounts: true } |
||||
|
} |
||||
|
}, |
||||
|
where: { userId } |
||||
|
}); |
||||
|
|
||||
|
return { |
||||
|
enabled: true, |
||||
|
items: items.map((item) => ({ |
||||
|
accountCount: item._count.accounts, |
||||
|
consentExpiresAt: item.consentExpiresAt?.toISOString() ?? null, |
||||
|
createdAt: item.createdAt.toISOString(), |
||||
|
error: item.error, |
||||
|
id: item.id, |
||||
|
institutionId: item.institutionId, |
||||
|
institutionName: item.institutionName, |
||||
|
lastSyncedAt: item.lastSyncedAt?.toISOString() ?? null |
||||
|
})) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public async triggerSync(userId: string, plaidItemId: string) { |
||||
|
this.ensureEnabled(); |
||||
|
|
||||
|
const plaidItem = await this.prismaService.plaidItem.findUnique({ |
||||
|
where: { id: plaidItemId } |
||||
|
}); |
||||
|
|
||||
|
if (!plaidItem) { |
||||
|
throw new NotFoundException('PlaidItem not found'); |
||||
|
} |
||||
|
|
||||
|
if (plaidItem.userId !== userId) { |
||||
|
throw new ForbiddenException('Not owner of PlaidItem'); |
||||
|
} |
||||
|
|
||||
|
const jobId = await this.plaidSyncService.enqueueSyncJob(plaidItemId); |
||||
|
|
||||
|
return { |
||||
|
jobId, |
||||
|
message: 'Sync job enqueued' |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public async deleteItem(userId: string, plaidItemId: string) { |
||||
|
this.ensureEnabled(); |
||||
|
|
||||
|
const plaidItem = await this.prismaService.plaidItem.findUnique({ |
||||
|
where: { id: plaidItemId } |
||||
|
}); |
||||
|
|
||||
|
if (!plaidItem) { |
||||
|
throw new NotFoundException('PlaidItem not found'); |
||||
|
} |
||||
|
|
||||
|
if (plaidItem.userId !== userId) { |
||||
|
throw new ForbiddenException('Not owner of PlaidItem'); |
||||
|
} |
||||
|
|
||||
|
// Try to remove from Plaid (best effort)
|
||||
|
try { |
||||
|
const decryptedToken = this.decryptAccessToken(plaidItem.accessToken); |
||||
|
await this.plaidClient.itemRemove({ |
||||
|
access_token: decryptedToken |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
this.logger.warn( |
||||
|
`Failed to remove Plaid item ${plaidItemId}: ${error.message}` |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Unlink accounts (clear plaidItemId, keep account data)
|
||||
|
await this.prismaService.account.updateMany({ |
||||
|
data: { |
||||
|
plaidAccountId: null, |
||||
|
plaidItemId: null |
||||
|
}, |
||||
|
where: { plaidItemId: plaidItem.id } |
||||
|
}); |
||||
|
|
||||
|
// Delete PlaidItem record
|
||||
|
await this.prismaService.plaidItem.delete({ |
||||
|
where: { id: plaidItemId } |
||||
|
}); |
||||
|
|
||||
|
return { message: 'Plaid connection disconnected' }; |
||||
|
} |
||||
|
|
||||
|
public async handleWebhook(payload: { |
||||
|
error?: object; |
||||
|
item_id: string; |
||||
|
webhook_code: string; |
||||
|
webhook_type: string; |
||||
|
}) { |
||||
|
const { item_id, webhook_code, webhook_type } = payload; |
||||
|
|
||||
|
this.logger.log( |
||||
|
`Plaid webhook received: ${webhook_type}.${webhook_code} for item ${item_id}` |
||||
|
); |
||||
|
|
||||
|
const plaidItem = await this.prismaService.plaidItem.findUnique({ |
||||
|
where: { itemId: item_id } |
||||
|
}); |
||||
|
|
||||
|
if (!plaidItem) { |
||||
|
this.logger.warn(`Webhook for unknown item: ${item_id}`); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
switch (webhook_type) { |
||||
|
case 'HOLDINGS': |
||||
|
case 'INVESTMENTS_TRANSACTIONS': |
||||
|
await this.plaidSyncService.enqueueSyncJob(plaidItem.id); |
||||
|
this.logger.log(`Sync job enqueued for item ${plaidItem.id}`); |
||||
|
break; |
||||
|
|
||||
|
case 'ITEM': |
||||
|
if (webhook_code === 'PENDING_EXPIRATION') { |
||||
|
await this.prismaService.plaidItem.update({ |
||||
|
data: { |
||||
|
consentExpiresAt: |
||||
|
(payload as any).consent_expiration_time ?? null, |
||||
|
error: 'PENDING_EXPIRATION' |
||||
|
}, |
||||
|
where: { id: plaidItem.id } |
||||
|
}); |
||||
|
} else if (webhook_code === 'ERROR') { |
||||
|
await this.prismaService.plaidItem.update({ |
||||
|
data: { |
||||
|
error: (payload.error as any)?.error_code ?? 'UNKNOWN_ERROR' |
||||
|
}, |
||||
|
where: { id: plaidItem.id } |
||||
|
}); |
||||
|
} |
||||
|
break; |
||||
|
|
||||
|
default: |
||||
|
this.logger.log( |
||||
|
`Unhandled webhook type: ${webhook_type}.${webhook_code}` |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// --- Encryption helpers ---
|
||||
|
|
||||
|
private encryptAccessToken(plainToken: string): string { |
||||
|
const key = this.getEncryptionKey(); |
||||
|
const iv = crypto.randomBytes(12); |
||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); |
||||
|
let encrypted = cipher.update(plainToken, 'utf8', 'hex'); |
||||
|
encrypted += cipher.final('hex'); |
||||
|
const authTag = cipher.getAuthTag().toString('hex'); |
||||
|
return `${iv.toString('hex')}:${authTag}:${encrypted}`; |
||||
|
} |
||||
|
|
||||
|
private decryptAccessToken(encryptedToken: string): string { |
||||
|
const key = this.getEncryptionKey(); |
||||
|
const [ivHex, authTagHex, encryptedHex] = encryptedToken.split(':'); |
||||
|
const iv = Buffer.from(ivHex, 'hex'); |
||||
|
const authTag = Buffer.from(authTagHex, 'hex'); |
||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); |
||||
|
decipher.setAuthTag(authTag); |
||||
|
let decrypted = decipher.update(encryptedHex, 'hex', 'utf8'); |
||||
|
decrypted += decipher.final('utf8'); |
||||
|
return decrypted; |
||||
|
} |
||||
|
|
||||
|
private getEncryptionKey(): Buffer { |
||||
|
const keyStr = this.configurationService.get('PLAID_ENCRYPTION_KEY'); |
||||
|
if (!keyStr || keyStr.length < 32) { |
||||
|
throw new Error( |
||||
|
'PLAID_ENCRYPTION_KEY must be at least 32 characters for AES-256' |
||||
|
); |
||||
|
} |
||||
|
// Use first 32 bytes of the key string
|
||||
|
return Buffer.from(keyStr.slice(0, 32), 'utf8'); |
||||
|
} |
||||
|
|
||||
|
private ensureEnabled() { |
||||
|
if (!this.isEnabled()) { |
||||
|
throw new ForbiddenException('Plaid feature is disabled'); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,167 @@ |
|||||
|
<div class="container p-4"> |
||||
|
@if (isLoading) { |
||||
|
<div class="d-flex justify-content-center py-5"> |
||||
|
<mat-spinner diameter="40" /> |
||||
|
</div> |
||||
|
} @else { |
||||
|
<!-- Hero: Total FMV --> |
||||
|
<mat-card class="hero-card mb-4"> |
||||
|
<mat-card-content class="text-center py-4"> |
||||
|
<div class="fmv-label text-muted mb-1" i18n> |
||||
|
Total Fair Market Value |
||||
|
</div> |
||||
|
<div class="fmv-value"> |
||||
|
<gf-value |
||||
|
size="large" |
||||
|
[isCurrency]="true" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
[unit]="baseCurrency" |
||||
|
[value]="totalValueInBaseCurrency" |
||||
|
/> |
||||
|
</div> |
||||
|
<div class="account-count text-muted mt-2"> |
||||
|
{{ accounts.length }} |
||||
|
<ng-container i18n> |
||||
|
{accounts.length, plural, =1 {Account} other {Accounts}} |
||||
|
</ng-container> |
||||
|
</div> |
||||
|
@if (isPlaidEnabled) { |
||||
|
<div class="mt-3"> |
||||
|
<button |
||||
|
class="mx-1" |
||||
|
mat-stroked-button |
||||
|
[disabled]="isLinkingPlaid" |
||||
|
(click)="onLinkPlaid()" |
||||
|
> |
||||
|
<mat-icon>link</mat-icon> |
||||
|
<span i18n>Link Account via Plaid</span> |
||||
|
</button> |
||||
|
</div> |
||||
|
} |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
|
||||
|
<!-- Plaid Connections --> |
||||
|
@if (plaidItems.length > 0) { |
||||
|
<div class="plaid-connections mb-4"> |
||||
|
<div class="d-flex align-items-center mb-2"> |
||||
|
<h3 class="m-0" i18n>Plaid Connections</h3> |
||||
|
</div> |
||||
|
<div class="connections-grid"> |
||||
|
@for (item of plaidItems; track item.id) { |
||||
|
<mat-card class="connection-card p-2"> |
||||
|
<mat-card-content |
||||
|
class="d-flex justify-content-between align-items-center" |
||||
|
> |
||||
|
<div> |
||||
|
<div class="font-weight-bold"> |
||||
|
{{ item.institutionName ?? 'Unknown Institution' }} |
||||
|
</div> |
||||
|
<div class="text-muted small"> |
||||
|
{{ item.accountCount }} |
||||
|
<ng-container i18n> |
||||
|
{item.accountCount, plural, =1 {account} other {accounts}} |
||||
|
</ng-container> |
||||
|
@if (item.lastSyncedAt) { |
||||
|
<span> |
||||
|
· |
||||
|
<span i18n>Last synced</span>: |
||||
|
{{ item.lastSyncedAt | date : 'short' }} |
||||
|
</span> |
||||
|
} |
||||
|
</div> |
||||
|
@if (item.error) { |
||||
|
<div class="text-danger small"> |
||||
|
<mat-icon class="error-icon">warning</mat-icon> |
||||
|
{{ item.error }} |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
<button |
||||
|
mat-icon-button |
||||
|
matTooltip="Refresh" |
||||
|
i18n-matTooltip |
||||
|
(click)="onRefreshPlaidItem(item.id)" |
||||
|
> |
||||
|
<mat-icon>refresh</mat-icon> |
||||
|
</button> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
<!-- Account Cards Grid --> |
||||
|
@if (accounts.length === 0) { |
||||
|
<mat-card class="text-center py-5"> |
||||
|
<mat-card-content> |
||||
|
<p class="text-muted" i18n> |
||||
|
No accounts found. Create accounts to see your fair market value |
||||
|
overview. |
||||
|
</p> |
||||
|
<a mat-flat-button color="primary" routerLink="/accounts" i18n |
||||
|
>Go to Accounts</a |
||||
|
> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
} @else { |
||||
|
<div class="accounts-grid"> |
||||
|
@for (account of accounts; track account.id) { |
||||
|
<mat-card |
||||
|
class="account-card" |
||||
|
[class.clickable]="true" |
||||
|
(click)="onAccountClick(account)" |
||||
|
> |
||||
|
<mat-card-content class="p-3"> |
||||
|
<div |
||||
|
class="d-flex justify-content-between align-items-start mb-2" |
||||
|
> |
||||
|
<div class="account-name font-weight-bold"> |
||||
|
{{ account.name }} |
||||
|
</div> |
||||
|
<div class="d-flex align-items-center gap-1"> |
||||
|
@if ($any(account).plaidItemId) { |
||||
|
<mat-icon |
||||
|
class="plaid-indicator text-success" |
||||
|
matTooltip="Linked via Plaid" |
||||
|
i18n-matTooltip |
||||
|
>link</mat-icon |
||||
|
> |
||||
|
} |
||||
|
@if (account.platform?.name) { |
||||
|
<div class="platform-badge text-muted small"> |
||||
|
{{ account.platform.name }} |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="account-value mb-2"> |
||||
|
<gf-value |
||||
|
size="medium" |
||||
|
[isCurrency]="true" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
[unit]="baseCurrency" |
||||
|
[value]="account.valueInBaseCurrency" |
||||
|
/> |
||||
|
</div> |
||||
|
<div class="d-flex justify-content-between align-items-center"> |
||||
|
<div class="allocation text-muted small"> |
||||
|
<gf-value |
||||
|
[isPercent]="true" |
||||
|
[locale]="user?.settings?.locale" |
||||
|
[value]="account.allocationInPercentage" |
||||
|
/> |
||||
|
<span i18n>allocation</span> |
||||
|
</div> |
||||
|
<div class="currency-badge text-muted small"> |
||||
|
{{ account.currency }} |
||||
|
</div> |
||||
|
</div> |
||||
|
</mat-card-content> |
||||
|
</mat-card> |
||||
|
} |
||||
|
</div> |
||||
|
} |
||||
|
} |
||||
|
</div> |
||||
@ -0,0 +1,78 @@ |
|||||
|
:host { |
||||
|
display: block; |
||||
|
} |
||||
|
|
||||
|
.hero-card { |
||||
|
.fmv-label { |
||||
|
font-size: 0.875rem; |
||||
|
text-transform: uppercase; |
||||
|
letter-spacing: 0.05em; |
||||
|
} |
||||
|
|
||||
|
.account-count { |
||||
|
font-size: 0.875rem; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.connections-grid { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); |
||||
|
gap: 0.75rem; |
||||
|
|
||||
|
.connection-card { |
||||
|
.error-icon { |
||||
|
font-size: 0.875rem; |
||||
|
width: 0.875rem; |
||||
|
height: 0.875rem; |
||||
|
vertical-align: text-bottom; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.accounts-grid { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); |
||||
|
gap: 1rem; |
||||
|
} |
||||
|
|
||||
|
.account-card { |
||||
|
cursor: pointer; |
||||
|
transition: box-shadow 0.2s ease; |
||||
|
|
||||
|
&:hover { |
||||
|
box-shadow: |
||||
|
0 4px 8px rgba(0, 0, 0, 0.12), |
||||
|
0 2px 4px rgba(0, 0, 0, 0.08); |
||||
|
} |
||||
|
|
||||
|
.account-name { |
||||
|
font-size: 1rem; |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
white-space: nowrap; |
||||
|
max-width: 70%; |
||||
|
} |
||||
|
|
||||
|
.platform-badge { |
||||
|
font-size: 0.75rem; |
||||
|
padding: 0.125rem 0.5rem; |
||||
|
border-radius: 0.75rem; |
||||
|
background: rgba(0, 0, 0, 0.04); |
||||
|
} |
||||
|
|
||||
|
.allocation { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 0.25rem; |
||||
|
} |
||||
|
|
||||
|
.currency-badge { |
||||
|
font-size: 0.75rem; |
||||
|
} |
||||
|
|
||||
|
.plaid-indicator { |
||||
|
font-size: 1rem; |
||||
|
width: 1rem; |
||||
|
height: 1rem; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,254 @@ |
|||||
|
import { GfAccountDetailDialogComponent } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component'; |
||||
|
import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces'; |
||||
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; |
||||
|
import { |
||||
|
PlaidItemSummary, |
||||
|
PlaidLinkService |
||||
|
} from '@ghostfolio/client/services/plaid-link.service'; |
||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
||||
|
import { AccountsResponse } from '@ghostfolio/common/interfaces'; |
||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
||||
|
import { AccountWithValue } from '@ghostfolio/common/types'; |
||||
|
import { GfValueComponent } from '@ghostfolio/ui/value'; |
||||
|
import { DataService } from '@ghostfolio/ui/services'; |
||||
|
|
||||
|
import { CommonModule, CurrencyPipe, DatePipe } from '@angular/common'; |
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
ChangeDetectorRef, |
||||
|
Component, |
||||
|
DestroyRef, |
||||
|
OnInit |
||||
|
} from '@angular/core'; |
||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { MatCardModule } from '@angular/material/card'; |
||||
|
import { MatDialog } from '@angular/material/dialog'; |
||||
|
import { MatIconModule } from '@angular/material/icon'; |
||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; |
||||
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; |
||||
|
import { MatTooltipModule } from '@angular/material/tooltip'; |
||||
|
import { RouterModule } from '@angular/router'; |
||||
|
import { DeviceDetectorService } from 'ngx-device-detector'; |
||||
|
import { switchMap } from 'rxjs'; |
||||
|
|
||||
|
import { User } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
CurrencyPipe, |
||||
|
DatePipe, |
||||
|
GfValueComponent, |
||||
|
MatButtonModule, |
||||
|
MatCardModule, |
||||
|
MatIconModule, |
||||
|
MatProgressSpinnerModule, |
||||
|
MatSnackBarModule, |
||||
|
MatTooltipModule, |
||||
|
RouterModule |
||||
|
], |
||||
|
selector: 'gf-fmv-page', |
||||
|
standalone: true, |
||||
|
templateUrl: './fmv-page.component.html', |
||||
|
styleUrls: ['./fmv-page.component.scss'] |
||||
|
}) |
||||
|
export class FmvPageComponent implements OnInit { |
||||
|
public accounts: AccountWithValue[] = []; |
||||
|
public baseCurrency: string; |
||||
|
public deviceType: string; |
||||
|
public hasImpersonationId = false; |
||||
|
public isLinkingPlaid = false; |
||||
|
public isLoading = true; |
||||
|
public isPlaidEnabled = false; |
||||
|
public plaidItems: PlaidItemSummary[] = []; |
||||
|
public totalValueInBaseCurrency = 0; |
||||
|
public user: User; |
||||
|
|
||||
|
public constructor( |
||||
|
private changeDetectorRef: ChangeDetectorRef, |
||||
|
private dataService: DataService, |
||||
|
private destroyRef: DestroyRef, |
||||
|
private deviceService: DeviceDetectorService, |
||||
|
private dialog: MatDialog, |
||||
|
private impersonationStorageService: ImpersonationStorageService, |
||||
|
private plaidLinkService: PlaidLinkService, |
||||
|
private snackBar: MatSnackBar, |
||||
|
private userService: UserService |
||||
|
) {} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType; |
||||
|
|
||||
|
this.impersonationStorageService |
||||
|
.onChangeHasImpersonation() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((aId) => { |
||||
|
this.hasImpersonationId = !!aId; |
||||
|
}); |
||||
|
|
||||
|
this.userService.stateChanged |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe((state) => { |
||||
|
if (state?.user) { |
||||
|
this.user = state.user; |
||||
|
this.baseCurrency = this.user?.settings?.baseCurrency; |
||||
|
this.fetchAccounts(); |
||||
|
this.fetchPlaidItems(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onAccountClick(account: AccountWithValue) { |
||||
|
this.openAccountDetailDialog(account.id); |
||||
|
} |
||||
|
|
||||
|
public onRefreshPlaidItem(plaidItemId: string) { |
||||
|
this.plaidLinkService |
||||
|
.triggerSync(plaidItemId) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe({ |
||||
|
next: () => { |
||||
|
this.snackBar.open('Sync started', undefined, { duration: 2000 }); |
||||
|
// Refresh after a short delay to let the sync begin
|
||||
|
setTimeout(() => { |
||||
|
this.fetchAccounts(); |
||||
|
this.fetchPlaidItems(); |
||||
|
}, 3000); |
||||
|
}, |
||||
|
error: () => { |
||||
|
this.snackBar.open('Failed to start sync', undefined, { |
||||
|
duration: 3000 |
||||
|
}); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public onLinkPlaid() { |
||||
|
this.isLinkingPlaid = true; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
|
||||
|
this.plaidLinkService |
||||
|
.createLinkToken() |
||||
|
.pipe( |
||||
|
takeUntilDestroyed(this.destroyRef), |
||||
|
switchMap((response) => |
||||
|
this.plaidLinkService.openPlaidLink(response.linkToken) |
||||
|
) |
||||
|
) |
||||
|
.subscribe({ |
||||
|
next: (result) => { |
||||
|
// Exchange the public token
|
||||
|
this.plaidLinkService |
||||
|
.exchangeToken({ |
||||
|
accounts: result.metadata.accounts, |
||||
|
institutionId: result.metadata.institution.institution_id, |
||||
|
institutionName: result.metadata.institution.name, |
||||
|
publicToken: result.publicToken |
||||
|
}) |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe({ |
||||
|
next: (exchangeResult) => { |
||||
|
this.snackBar.open( |
||||
|
`Linked ${exchangeResult.accounts.length} account(s) — syncing holdings…`, |
||||
|
undefined, |
||||
|
{ duration: 5000 } |
||||
|
); |
||||
|
this.isLinkingPlaid = false; |
||||
|
this.fetchAccounts(); |
||||
|
this.fetchPlaidItems(); |
||||
|
|
||||
|
// Re-fetch after sync has time to complete
|
||||
|
setTimeout(() => { |
||||
|
this.fetchAccounts(); |
||||
|
this.fetchPlaidItems(); |
||||
|
}, 8000); |
||||
|
}, |
||||
|
error: () => { |
||||
|
this.snackBar.open( |
||||
|
'Failed to link account', |
||||
|
undefined, |
||||
|
{ duration: 3000 } |
||||
|
); |
||||
|
this.isLinkingPlaid = false; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
} |
||||
|
}); |
||||
|
}, |
||||
|
error: () => { |
||||
|
this.isLinkingPlaid = false; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}, |
||||
|
complete: () => { |
||||
|
this.isLinkingPlaid = false; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private fetchAccounts() { |
||||
|
this.isLoading = true; |
||||
|
|
||||
|
this.dataService |
||||
|
.fetchAccounts() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe({ |
||||
|
next: (response: AccountsResponse) => { |
||||
|
this.accounts = response.accounts.filter( |
||||
|
(account) => !account.isExcluded |
||||
|
); |
||||
|
this.totalValueInBaseCurrency = response.totalValueInBaseCurrency; |
||||
|
this.isLoading = false; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}, |
||||
|
error: () => { |
||||
|
this.isLoading = false; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private fetchPlaidItems() { |
||||
|
this.plaidLinkService |
||||
|
.getItems() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe({ |
||||
|
next: (response) => { |
||||
|
this.isPlaidEnabled = response.enabled; |
||||
|
this.plaidItems = response.items; |
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}, |
||||
|
error: () => { |
||||
|
this.isPlaidEnabled = false; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private openAccountDetailDialog(aAccountId: string) { |
||||
|
const dialogRef = this.dialog.open< |
||||
|
GfAccountDetailDialogComponent, |
||||
|
AccountDetailDialogParams |
||||
|
>(GfAccountDetailDialogComponent, { |
||||
|
autoFocus: false, |
||||
|
data: { |
||||
|
accountId: aAccountId, |
||||
|
deviceType: this.deviceType, |
||||
|
hasImpersonationId: this.hasImpersonationId, |
||||
|
hasPermissionToCreateActivity: |
||||
|
!this.hasImpersonationId && |
||||
|
hasPermission(this.user?.permissions, permissions.createActivity) && |
||||
|
!this.user?.settings?.isRestrictedView |
||||
|
}, |
||||
|
height: this.deviceType === 'mobile' ? '98vh' : '80vh', |
||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem' |
||||
|
}); |
||||
|
|
||||
|
dialogRef |
||||
|
.afterClosed() |
||||
|
.pipe(takeUntilDestroyed(this.destroyRef)) |
||||
|
.subscribe(() => { |
||||
|
this.fetchAccounts(); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,14 @@ |
|||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; |
||||
|
|
||||
|
import { Routes } from '@angular/router'; |
||||
|
|
||||
|
import { FmvPageComponent } from './fmv-page.component'; |
||||
|
|
||||
|
export const routes: Routes = [ |
||||
|
{ |
||||
|
canActivate: [AuthGuard], |
||||
|
component: FmvPageComponent, |
||||
|
path: '', |
||||
|
title: $localize`FMV Dashboard` |
||||
|
} |
||||
|
]; |
||||
@ -0,0 +1,197 @@ |
|||||
|
import { HttpClient } from '@angular/common/http'; |
||||
|
import { Injectable, NgZone } from '@angular/core'; |
||||
|
import { Observable, Subject } from 'rxjs'; |
||||
|
|
||||
|
declare global { |
||||
|
interface Window { |
||||
|
Plaid: { |
||||
|
create: (config: PlaidLinkConfig) => PlaidLinkHandler; |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
interface PlaidLinkConfig { |
||||
|
token: string; |
||||
|
onSuccess: (publicToken: string, metadata: PlaidLinkMetadata) => void; |
||||
|
onExit: (err: any, metadata: any) => void; |
||||
|
onEvent?: (eventName: string, metadata: any) => void; |
||||
|
} |
||||
|
|
||||
|
interface PlaidLinkHandler { |
||||
|
open: () => void; |
||||
|
destroy: () => void; |
||||
|
} |
||||
|
|
||||
|
export interface PlaidLinkMetadata { |
||||
|
institution: { |
||||
|
institution_id: string; |
||||
|
name: string; |
||||
|
}; |
||||
|
accounts: { |
||||
|
id: string; |
||||
|
name: string; |
||||
|
type: string; |
||||
|
subtype: string; |
||||
|
mask: string; |
||||
|
}[]; |
||||
|
link_session_id: string; |
||||
|
} |
||||
|
|
||||
|
export interface PlaidLinkResult { |
||||
|
publicToken: string; |
||||
|
metadata: PlaidLinkMetadata; |
||||
|
} |
||||
|
|
||||
|
export interface PlaidLinkTokenResponse { |
||||
|
linkToken: string; |
||||
|
expiration: string; |
||||
|
} |
||||
|
|
||||
|
export interface PlaidExchangeTokenRequest { |
||||
|
publicToken: string; |
||||
|
institutionId: string; |
||||
|
institutionName: string; |
||||
|
accounts: { |
||||
|
id: string; |
||||
|
name: string; |
||||
|
type: string; |
||||
|
subtype: string; |
||||
|
mask: string; |
||||
|
}[]; |
||||
|
} |
||||
|
|
||||
|
export interface PlaidExchangeTokenResponse { |
||||
|
plaidItemId: string; |
||||
|
accounts: { |
||||
|
accountId: string; |
||||
|
plaidAccountId: string; |
||||
|
name: string; |
||||
|
}[]; |
||||
|
} |
||||
|
|
||||
|
export interface PlaidItemSummary { |
||||
|
id: string; |
||||
|
institutionId: string | null; |
||||
|
institutionName: string | null; |
||||
|
lastSyncedAt: string | null; |
||||
|
error: string | null; |
||||
|
consentExpiresAt: string | null; |
||||
|
accountCount: number; |
||||
|
createdAt: string; |
||||
|
} |
||||
|
|
||||
|
export interface PlaidItemsResponse { |
||||
|
enabled: boolean; |
||||
|
items: PlaidItemSummary[]; |
||||
|
} |
||||
|
|
||||
|
@Injectable({ |
||||
|
providedIn: 'root' |
||||
|
}) |
||||
|
export class PlaidLinkService { |
||||
|
private static PLAID_SCRIPT_URL = |
||||
|
'https://cdn.plaid.com/link/v2/stable/link-initialize.js'; |
||||
|
private scriptLoaded = false; |
||||
|
|
||||
|
public constructor( |
||||
|
private readonly http: HttpClient, |
||||
|
private readonly ngZone: NgZone |
||||
|
) {} |
||||
|
|
||||
|
public createLinkToken(): Observable<PlaidLinkTokenResponse> { |
||||
|
return this.http.post<PlaidLinkTokenResponse>( |
||||
|
'/api/v1/plaid/link-token', |
||||
|
{} |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public exchangeToken( |
||||
|
body: PlaidExchangeTokenRequest |
||||
|
): Observable<PlaidExchangeTokenResponse> { |
||||
|
return this.http.post<PlaidExchangeTokenResponse>( |
||||
|
'/api/v1/plaid/exchange-token', |
||||
|
body |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public getItems(): Observable<PlaidItemsResponse> { |
||||
|
return this.http.get<PlaidItemsResponse>('/api/v1/plaid/items'); |
||||
|
} |
||||
|
|
||||
|
public deleteItem( |
||||
|
plaidItemId: string |
||||
|
): Observable<{ message: string }> { |
||||
|
return this.http.delete<{ message: string }>( |
||||
|
`/api/v1/plaid/items/${plaidItemId}` |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public createUpdateLinkToken( |
||||
|
plaidItemId: string |
||||
|
): Observable<PlaidLinkTokenResponse> { |
||||
|
return this.http.post<PlaidLinkTokenResponse>( |
||||
|
`/api/v1/plaid/link-token/update/${plaidItemId}`, |
||||
|
{} |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public triggerSync( |
||||
|
plaidItemId: string |
||||
|
): Observable<{ jobId: string; message: string }> { |
||||
|
return this.http.post<{ jobId: string; message: string }>( |
||||
|
`/api/v1/plaid/sync/${plaidItemId}`, |
||||
|
{} |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Opens the Plaid Link modal with the given token. |
||||
|
* Returns an Observable that emits the result on success. |
||||
|
*/ |
||||
|
public openPlaidLink(linkToken: string): Observable<PlaidLinkResult> { |
||||
|
const result$ = new Subject<PlaidLinkResult>(); |
||||
|
|
||||
|
this.loadPlaidScript().then(() => { |
||||
|
const handler = window.Plaid.create({ |
||||
|
token: linkToken, |
||||
|
onSuccess: (publicToken, metadata) => { |
||||
|
this.ngZone.run(() => { |
||||
|
result$.next({ publicToken, metadata }); |
||||
|
result$.complete(); |
||||
|
}); |
||||
|
handler.destroy(); |
||||
|
}, |
||||
|
onExit: (err) => { |
||||
|
this.ngZone.run(() => { |
||||
|
if (err) { |
||||
|
result$.error(err); |
||||
|
} else { |
||||
|
result$.complete(); |
||||
|
} |
||||
|
}); |
||||
|
handler.destroy(); |
||||
|
} |
||||
|
}); |
||||
|
handler.open(); |
||||
|
}); |
||||
|
|
||||
|
return result$.asObservable(); |
||||
|
} |
||||
|
|
||||
|
private loadPlaidScript(): Promise<void> { |
||||
|
if (this.scriptLoaded) { |
||||
|
return Promise.resolve(); |
||||
|
} |
||||
|
|
||||
|
return new Promise((resolve, reject) => { |
||||
|
const script = document.createElement('script'); |
||||
|
script.src = PlaidLinkService.PLAID_SCRIPT_URL; |
||||
|
script.onload = () => { |
||||
|
this.scriptLoaded = true; |
||||
|
resolve(); |
||||
|
}; |
||||
|
script.onerror = () => reject(new Error('Failed to load Plaid script')); |
||||
|
document.head.appendChild(script); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,36 @@ |
|||||
|
# Specification Quality Checklist: FMV Portfolio View with Plaid Account Linking & Asset Drill-Down |
||||
|
|
||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning |
||||
|
**Created**: 2026-03-22 |
||||
|
**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. Spec is ready for `/speckit.clarify` or `/speckit.plan`. |
||||
|
- The Markets page permission gap (USER lacks `readMarketData`) is documented as an assumption to be resolved during planning, not as a [NEEDS CLARIFICATION] since it's a known constraint with a clear resolution path. |
||||
|
- Plaid product scope is explicitly bounded to "Investments" only — bank transactions are out of scope. |
||||
@ -0,0 +1,103 @@ |
|||||
|
# API Contracts: Navigation & FMV Changes |
||||
|
|
||||
|
No new API endpoints are needed for the navigation restoration (US1) or FMV Dashboard (US2/US3). These features reuse existing endpoints. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Existing Endpoints Used by FMV Dashboard |
||||
|
|
||||
|
### GET /api/v1/account |
||||
|
|
||||
|
Already returns `AccountsResponse` — provides all data for the FMV dashboard. |
||||
|
|
||||
|
**Response** (unchanged): |
||||
|
```typescript |
||||
|
interface AccountsResponse { |
||||
|
accounts: AccountWithValue[]; |
||||
|
activitiesCount: number; |
||||
|
totalBalanceInBaseCurrency: number; |
||||
|
totalDividendInBaseCurrency: number; |
||||
|
totalInterestInBaseCurrency: number; |
||||
|
totalValueInBaseCurrency: number; // ← The aggregate FMV |
||||
|
} |
||||
|
|
||||
|
type AccountWithValue = Account & { |
||||
|
activitiesCount: number; |
||||
|
allocationInPercentage: number; |
||||
|
balanceInBaseCurrency: number; |
||||
|
dividendInBaseCurrency: number; |
||||
|
interestInBaseCurrency: number; |
||||
|
platform?: Platform; |
||||
|
value: number; |
||||
|
valueInBaseCurrency: number; // ← Per-account FMV |
||||
|
}; |
||||
|
``` |
||||
|
|
||||
|
### GET /api/v1/portfolio/holdings?accounts[]=:accountId |
||||
|
|
||||
|
Returns `PortfolioPosition[]` for a specific account — used in account drill-down. |
||||
|
|
||||
|
**Response** (unchanged): |
||||
|
```typescript |
||||
|
interface PortfolioPosition { |
||||
|
activitiesCount: number; |
||||
|
allocationInPercentage: number; |
||||
|
assetProfile: { |
||||
|
assetClass: AssetClass; |
||||
|
assetSubClass: AssetSubClass; |
||||
|
currency: string; |
||||
|
dataSource: DataSource; |
||||
|
name: string; |
||||
|
symbol: string; |
||||
|
// ... |
||||
|
}; |
||||
|
investment: number; // ← Cost basis |
||||
|
marketPrice: number; |
||||
|
netPerformance: number; // ← Unrealized gain/loss |
||||
|
netPerformancePercent: number; // ← Unrealized gain/loss % |
||||
|
quantity: number; |
||||
|
valueInBaseCurrency: number; // ← Current market value |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### GET /api/v2/portfolio/performance?accounts[]=:accountId&range=max |
||||
|
|
||||
|
Returns performance chart data filtered by account — used in account detail. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Client-Side Changes (No API Impact) |
||||
|
|
||||
|
### Navigation Structure Change |
||||
|
|
||||
|
**Before** (current): |
||||
|
``` |
||||
|
FO Dashboard | Partnerships ▼ | Portfolio Views ▼ | K-1 Center ▼ | Analysis ▼ | [Admin ▼ (admin-only)] |
||||
|
├─ Admin Control |
||||
|
├─ Accounts |
||||
|
├─ Resources |
||||
|
├─ ────── |
||||
|
├─ Overview |
||||
|
├─ Holdings |
||||
|
├─ Summary |
||||
|
├─ Markets |
||||
|
├─ Watchlist |
||||
|
├─ FIRE |
||||
|
└─ X-Ray |
||||
|
``` |
||||
|
|
||||
|
**After** (proposed): |
||||
|
``` |
||||
|
FO Dashboard | FMV ▼ | Partnerships ▼ | Portfolio Views ▼ | K-1 Center ▼ | Analysis ▼ | [Admin ▼ (admin-only)] |
||||
|
├─ Dashboard (new) ├─ Overview |
||||
|
└─ Accounts (existing /accounts) ├─ Holdings |
||||
|
├─ Summary |
||||
|
├─ Markets |
||||
|
├─ Watchlist |
||||
|
├─ FIRE |
||||
|
└─ X-Ray |
||||
|
``` |
||||
|
|
||||
|
- Legacy items (Overview, Holdings, Summary, Markets, Watchlist, FIRE, X-Ray) move to "Analysis" dropdown — visible to all authenticated users |
||||
|
- New "FMV" dropdown with Dashboard and Accounts |
||||
|
- Admin dropdown retains: Admin Control, Resources, Pricing |
||||
@ -0,0 +1,182 @@ |
|||||
|
# API Contracts: Plaid Integration |
||||
|
|
||||
|
**Base URL**: `/api/v1/plaid` |
||||
|
**Auth**: JWT Bearer token (all endpoints) |
||||
|
**Feature Gate**: `ENABLE_FEATURE_PLAID` must be `true` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## POST /api/v1/plaid/link-token |
||||
|
|
||||
|
Create a Plaid Link token for the client to open the Link modal. |
||||
|
|
||||
|
**Permission**: Any authenticated user |
||||
|
|
||||
|
**Request**: Empty body |
||||
|
|
||||
|
**Response** `200 OK`: |
||||
|
```typescript |
||||
|
interface CreateLinkTokenResponse { |
||||
|
linkToken: string; |
||||
|
expiration: string; // ISO 8601 datetime |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**Errors**: |
||||
|
- `403` — Plaid feature disabled |
||||
|
- `500` — Plaid API error |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## POST /api/v1/plaid/exchange-token |
||||
|
|
||||
|
Exchange a Plaid public token for an access token, create PlaidItem and Account(s). |
||||
|
|
||||
|
**Permission**: Any authenticated user |
||||
|
|
||||
|
**Request**: |
||||
|
```typescript |
||||
|
interface ExchangeTokenRequest { |
||||
|
publicToken: string; |
||||
|
institutionId: string; |
||||
|
institutionName: string; |
||||
|
accounts: { |
||||
|
id: string; // Plaid account_id |
||||
|
name: string; // Account display name |
||||
|
type: string; // e.g., 'investment' |
||||
|
subtype: string; // e.g., 'brokerage' |
||||
|
mask: string; // Last 4 digits (e.g., '1234') |
||||
|
}[]; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**Response** `201 Created`: |
||||
|
```typescript |
||||
|
interface ExchangeTokenResponse { |
||||
|
plaidItemId: string; |
||||
|
accounts: { |
||||
|
accountId: string; // Ghostfolio account ID |
||||
|
plaidAccountId: string; |
||||
|
name: string; |
||||
|
}[]; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**Errors**: |
||||
|
- `400` — Invalid or expired public token |
||||
|
- `403` — Plaid feature disabled |
||||
|
- `409` — Institution already linked (duplicate itemId) |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## POST /api/v1/plaid/sync/:plaidItemId |
||||
|
|
||||
|
Trigger an on-demand sync for a specific Plaid item. |
||||
|
|
||||
|
**Permission**: Any authenticated user (must own the PlaidItem) |
||||
|
|
||||
|
**Request**: Empty body |
||||
|
|
||||
|
**Response** `202 Accepted`: |
||||
|
```typescript |
||||
|
interface SyncResponse { |
||||
|
jobId: string; |
||||
|
message: string; // "Sync job enqueued" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**Errors**: |
||||
|
- `403` — Not owner of PlaidItem, or Plaid feature disabled |
||||
|
- `404` — PlaidItem not found |
||||
|
- `409` — Sync already in progress |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## GET /api/v1/plaid/items |
||||
|
|
||||
|
List all PlaidItems for the current user. |
||||
|
|
||||
|
**Permission**: Any authenticated user |
||||
|
|
||||
|
**Response** `200 OK`: |
||||
|
```typescript |
||||
|
interface PlaidItemsResponse { |
||||
|
items: { |
||||
|
id: string; |
||||
|
institutionId: string | null; |
||||
|
institutionName: string | null; |
||||
|
lastSyncedAt: string | null; // ISO 8601 |
||||
|
error: string | null; |
||||
|
consentExpiresAt: string | null; |
||||
|
accountCount: number; |
||||
|
createdAt: string; |
||||
|
}[]; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## POST /api/v1/plaid/link-token/update/:plaidItemId |
||||
|
|
||||
|
Create a Link token in update mode for re-authentication. |
||||
|
|
||||
|
**Permission**: Any authenticated user (must own the PlaidItem) |
||||
|
|
||||
|
**Request**: Empty body |
||||
|
|
||||
|
**Response** `200 OK`: |
||||
|
```typescript |
||||
|
interface CreateLinkTokenResponse { |
||||
|
linkToken: string; |
||||
|
expiration: string; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**Errors**: |
||||
|
- `403` — Not owner, or Plaid feature disabled |
||||
|
- `404` — PlaidItem not found |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## DELETE /api/v1/plaid/items/:plaidItemId |
||||
|
|
||||
|
Disconnect a Plaid item (marks as disconnected, preserves data). |
||||
|
|
||||
|
**Permission**: Any authenticated user (must own the PlaidItem) |
||||
|
|
||||
|
**Response** `200 OK`: |
||||
|
```typescript |
||||
|
interface DeletePlaidItemResponse { |
||||
|
message: string; // "Plaid connection disconnected" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**Errors**: |
||||
|
- `403` — Not owner, or Plaid feature disabled |
||||
|
- `404` — PlaidItem not found |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## POST /api/v1/plaid/webhook |
||||
|
|
||||
|
Receive webhooks from Plaid (no JWT — verified via Plaid webhook verification). |
||||
|
|
||||
|
**Authentication**: Plaid webhook verification (JWT in request body, verified against Plaid public keys) |
||||
|
|
||||
|
**Request**: Plaid webhook payload |
||||
|
```typescript |
||||
|
interface PlaidWebhookPayload { |
||||
|
webhook_type: 'HOLDINGS' | 'INVESTMENTS_TRANSACTIONS' | 'ITEM'; |
||||
|
webhook_code: string; |
||||
|
item_id: string; |
||||
|
error?: object; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**Response** `200 OK`: Empty (acknowledge receipt) |
||||
|
|
||||
|
**Behavior**: |
||||
|
- `HOLDINGS:DEFAULT_UPDATE` → Enqueue sync job for the item |
||||
|
- `INVESTMENTS_TRANSACTIONS:DEFAULT_UPDATE` → Enqueue sync job for the item |
||||
|
- `ITEM:PENDING_EXPIRATION` → Update PlaidItem.error and consentExpiresAt |
||||
|
- `ITEM:ERROR` → Update PlaidItem.error field |
||||
@ -0,0 +1,170 @@ |
|||||
|
# Data Model: 009-fmv-plaid-drilldown |
||||
|
|
||||
|
**Date**: 2026-03-22 |
||||
|
|
||||
|
## New Entities |
||||
|
|
||||
|
### PlaidItem |
||||
|
|
||||
|
Represents a Plaid Link connection to a financial institution. |
||||
|
|
||||
|
| Field | Type | Constraints | Description | |
||||
|
|---|---|---|---| |
||||
|
| `id` | `String` | PK, UUID, auto-generated | Internal identifier | |
||||
|
| `userId` | `String` | FK → User.id, required | Owning user | |
||||
|
| `itemId` | `String` | Unique, required | Plaid item_id (public reference) | |
||||
|
| `accessToken` | `String` | Required | AES-256-GCM encrypted Plaid access_token | |
||||
|
| `institutionId` | `String?` | Optional | Plaid institution_id (e.g., `ins_3`) | |
||||
|
| `institutionName` | `String?` | Optional | Human-readable institution name (e.g., "Vanguard") | |
||||
|
| `cursor` | `String?` | Optional | Plaid sync cursor for incremental updates | |
||||
|
| `consentExpiresAt` | `DateTime?` | Optional | When Plaid consent expires (for re-auth nudge) | |
||||
|
| `lastSyncedAt` | `DateTime?` | Optional | Timestamp of last successful sync | |
||||
|
| `error` | `String?` | Optional | Last error code from Plaid (e.g., `ITEM_LOGIN_REQUIRED`) | |
||||
|
| `createdAt` | `DateTime` | Auto, default now | Record creation timestamp | |
||||
|
| `updatedAt` | `DateTime` | Auto, @updatedAt | Last update timestamp | |
||||
|
|
||||
|
**Relationships**: |
||||
|
- `user: User` — many-to-one (FK: userId → User.id, onDelete: Cascade) |
||||
|
- `accounts: Account[]` — one-to-many (via Account.plaidItemId) |
||||
|
|
||||
|
**Indexes**: `userId`, `itemId` (unique) |
||||
|
|
||||
|
```prisma |
||||
|
model PlaidItem { |
||||
|
accounts Account[] |
||||
|
accessToken String |
||||
|
consentExpiresAt DateTime? |
||||
|
createdAt DateTime @default(now()) |
||||
|
cursor String? |
||||
|
error String? |
||||
|
id String @id @default(uuid()) |
||||
|
institutionId String? |
||||
|
institutionName String? |
||||
|
itemId String @unique |
||||
|
lastSyncedAt DateTime? |
||||
|
updatedAt DateTime @updatedAt |
||||
|
user User @relation(fields: [userId], onDelete: Cascade, references: [id]) |
||||
|
userId String |
||||
|
|
||||
|
@@index([userId]) |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Modified Entities |
||||
|
|
||||
|
### Account (extended) |
||||
|
|
||||
|
Two new optional fields added: |
||||
|
|
||||
|
| Field | Type | Constraints | Description | |
||||
|
|---|---|---|---| |
||||
|
| `plaidItemId` | `String?` | FK → PlaidItem.id, optional | Link to Plaid connection (null for manual accounts) | |
||||
|
| `plaidAccountId` | `String?` | Optional | Plaid's account_id for API calls | |
||||
|
| `accountType` | `String?` | Optional | Plaid account type (e.g., 'investment', 'depository') | |
||||
|
|
||||
|
**New relationship**: |
||||
|
- `plaidItem: PlaidItem?` — many-to-one (FK: plaidItemId → PlaidItem.id) |
||||
|
|
||||
|
```prisma |
||||
|
model Account { |
||||
|
// ... existing fields ... |
||||
|
accountType String? |
||||
|
plaidAccountId String? |
||||
|
plaidItem PlaidItem? @relation(fields: [plaidItemId], references: [id]) |
||||
|
plaidItemId String? |
||||
|
// ... existing fields ... |
||||
|
|
||||
|
@@index([plaidItemId]) |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### User (extended) |
||||
|
|
||||
|
New relationship only (no schema field change — Prisma infers from PlaidItem.userId): |
||||
|
|
||||
|
```prisma |
||||
|
model User { |
||||
|
// ... existing fields ... |
||||
|
plaidItems PlaidItem[] |
||||
|
// ... existing fields ... |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Unchanged Entities (used as-is) |
||||
|
|
||||
|
### SymbolProfile |
||||
|
- Plaid securities map to existing SymbolProfile records |
||||
|
- Match by `symbol` (ticker) first; create MANUAL-type profile if no match |
||||
|
- Fields used: `assetClass`, `assetSubClass`, `symbol`, `name`, `currency`, `dataSource` |
||||
|
|
||||
|
### Order |
||||
|
- Plaid investment transactions become Order records |
||||
|
- Fields used: `type` (BUY/SELL/DIVIDEND), `quantity`, `unitPrice`, `currency`, `date`, `accountId` |
||||
|
- New orders created with `isDraft: false` |
||||
|
- Plaid-sourced orders distinguished by `comment` field (e.g., `"plaid-sync:{transactionId}"`) to avoid re-importing |
||||
|
|
||||
|
### MarketData |
||||
|
- Current prices from Plaid sync stored as MarketData entries |
||||
|
- Used by `PortfolioCalculator` for FMV computation |
||||
|
|
||||
|
### AccountBalance |
||||
|
- Plaid account balance synced via existing `updateAccountBalance()` method |
||||
|
- Creates dated balance snapshots |
||||
|
|
||||
|
### Platform |
||||
|
- Plaid institution mapped to Platform record |
||||
|
- Match by name or create new with `name: institutionName`, `url: null` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## State Transitions |
||||
|
|
||||
|
### PlaidItem Lifecycle |
||||
|
|
||||
|
``` |
||||
|
[Created] → ACTIVE → PENDING_EXPIRATION → EXPIRED |
||||
|
↓ ↓ |
||||
|
ERROR ← ← ← ← ← ← ← ← ← ← ← |
||||
|
↓ |
||||
|
ACTIVE (after re-auth) |
||||
|
``` |
||||
|
|
||||
|
States tracked via `error` field: |
||||
|
- `error = null` → Active, syncing normally |
||||
|
- `error = 'ITEM_LOGIN_REQUIRED'` → Needs re-authentication |
||||
|
- `error = 'PENDING_EXPIRATION'` → Consent expiring soon |
||||
|
- `error = 'ITEM_REMOVED'` → Item was removed at institution |
||||
|
- `consentExpiresAt < now()` → Expired, needs re-auth |
||||
|
|
||||
|
### Order Creation from Plaid |
||||
|
|
||||
|
``` |
||||
|
Plaid investmentsTransactionsGet |
||||
|
→ For each transaction: |
||||
|
→ Check if Order exists (comment contains plaid transaction ID) |
||||
|
→ If not: Map type → Create Order → Emit PortfolioChangedEvent |
||||
|
``` |
||||
|
|
||||
|
Plaid transaction type → Order type mapping: |
||||
|
| Plaid `type` | Order `Type` | |
||||
|
|---|---| |
||||
|
| `buy` | `BUY` | |
||||
|
| `sell` | `SELL` | |
||||
|
| `cash` (dividend subtype) | `DIVIDEND` | |
||||
|
| `fee` | `FEE` | |
||||
|
| `transfer` | Ignored (internal movement) | |
||||
|
| `cancel` | Ignored (offsetting entry) | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## Validation Rules |
||||
|
|
||||
|
1. **PlaidItem.accessToken**: Must be non-empty, encrypted before storage |
||||
|
2. **PlaidItem.itemId**: Must be unique across all users (Plaid guarantees uniqueness) |
||||
|
3. **Account.plaidItemId**: If set, the referenced PlaidItem must belong to the same userId |
||||
|
4. **Account.plaidAccountId**: If set, plaidItemId must also be set |
||||
|
5. **Order from Plaid**: Comment field with `plaid-sync:` prefix acts as idempotency key — prevents duplicate imports on re-sync |
||||
@ -0,0 +1,109 @@ |
|||||
|
# Implementation Plan: FMV Portfolio View with Plaid Account Linking & Asset Drill-Down |
||||
|
|
||||
|
**Branch**: `009-fmv-plaid-drilldown` | **Date**: 2026-03-22 | **Spec**: [spec.md](spec.md) |
||||
|
**Input**: Feature specification from `/specs/009-fmv-plaid-drilldown/spec.md` |
||||
|
|
||||
|
## Summary |
||||
|
|
||||
|
Restore 7 admin-gated portfolio features (Overview, Holdings, Summary, Markets, Watchlist, FIRE, X-Ray) for all authenticated users by moving them out of the `accessAdminControl` nav gate. Add an FMV Dashboard page aggregating account values (reusing existing `getAccountsWithAggregations()` API). Wire account cards to the existing account-detail-dialog for asset drill-down. Integrate Plaid for automated brokerage account linking and ongoing investment sync via a new `PlaidItem` model, NestJS Plaid module, `@plaid/link-initialize` Angular client, and a BullMQ sync queue. |
||||
|
|
||||
|
## Technical Context |
||||
|
|
||||
|
**Language/Version**: TypeScript 5.x (strict mode) |
||||
|
**Primary Dependencies**: Angular 21+ (standalone components, signals), NestJS 11+ (module-based DI), Prisma ORM, `plaid` v41+ (Node SDK), `@plaid/link-initialize` (client), `@nestjs/bull` (BullMQ) |
||||
|
**Storage**: PostgreSQL (Docker port 5434→5432), Redis (Docker port 6379→6379) |
||||
|
**Testing**: Jest (unit + integration) |
||||
|
**Target Platform**: Web application (server: Node.js, client: SPA) |
||||
|
**Project Type**: Nx monorepo web-service + SPA (apps: `api`, `client`; libs: `common`, `ui`) |
||||
|
**Performance Goals**: FMV Dashboard < 3s load, drill-down < 2 clicks, Plaid Link < 2 min, 50 holdings load < 5s |
||||
|
**Constraints**: Plaid rate limit 100 req/min (production), access tokens encrypted at rest (AES-256-GCM) |
||||
|
**Scale/Scope**: Single-tenant family office, ~5 accounts, ~50 holdings, 1 user |
||||
|
|
||||
|
## Constitution Check |
||||
|
|
||||
|
_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._ |
||||
|
|
||||
|
| Principle | Status | Notes | |
||||
|
|---|---|---| |
||||
|
| **I. Nx Monorepo Structure** | PASS | All changes within existing `api`, `client`, `common` projects. No new Nx projects. | |
||||
|
| **II. NestJS Module Pattern** | PASS | New `PlaidModule` follows module/controller/service pattern. Queue follows existing `DataGatheringModule` pattern. | |
||||
|
| **III. Prisma Data Layer** | PASS | New `PlaidItem` model + `Account` extension via Prisma migration. No direct SQL. | |
||||
|
| **IV. TypeScript Strict** | PASS | All code under strict mode, path aliases used. | |
||||
|
| **V. Simplicity First** | PASS | US1 is nav-only (no API changes). US2/US3 reuse existing API endpoints. US4/US5 add new module only where needed. Max 3 projects touched: `api`, `client`, `common`. | |
||||
|
| **VI. Interface-First Design** | PASS | Contracts defined in `contracts/` before implementation. Shared types in `@ghostfolio/common`. | |
||||
|
| **Max 3 Nx projects per feature** | PASS | `api` (Plaid module, migration), `client` (FMV page, nav changes), `common` (shared interfaces). No `ui` lib changes needed. | |
||||
|
|
||||
|
**Post-Phase 1 Re-Check**: PASS — No violations introduced during design. PlaidItem model is minimal. Existing APIs reused for FMV/drill-down. One new NestJS module for Plaid. |
||||
|
|
||||
|
## Project Structure |
||||
|
|
||||
|
### Documentation (this feature) |
||||
|
|
||||
|
```text |
||||
|
specs/009-fmv-plaid-drilldown/ |
||||
|
├── plan.md # This file |
||||
|
├── research.md # Phase 0 output — 8 research decisions |
||||
|
├── data-model.md # Phase 1 output — PlaidItem model, Account extensions |
||||
|
├── quickstart.md # Phase 1 output — setup & verification steps |
||||
|
├── contracts/ # Phase 1 output — API contracts |
||||
|
│ ├── plaid-api.md # 7 Plaid endpoints |
||||
|
│ └── navigation-fmv.md # Nav restructure + existing endpoint usage |
||||
|
└── tasks.md # Phase 2 output (NOT created by /speckit.plan) |
||||
|
``` |
||||
|
|
||||
|
### Source Code (repository root) |
||||
|
|
||||
|
```text |
||||
|
apps/api/src/ |
||||
|
├── app/ |
||||
|
│ ├── plaid/ # NEW — NestJS Plaid module |
||||
|
│ │ ├── plaid.module.ts # Module registering providers, imports |
||||
|
│ │ ├── plaid.controller.ts # HTTP endpoints (link-token, exchange, sync, webhook) |
||||
|
│ │ ├── plaid.service.ts # Business logic (Plaid API calls, token encryption) |
||||
|
│ │ └── interfaces/ # Request/response DTOs |
||||
|
│ │ ├── create-link-token.interface.ts |
||||
|
│ │ └── exchange-token.interface.ts |
||||
|
│ └── ...existing modules unchanged |
||||
|
├── services/ |
||||
|
│ ├── queues/ |
||||
|
│ │ └── plaid-sync/ # NEW — BullMQ queue for Plaid sync |
||||
|
│ │ ├── plaid-sync.module.ts |
||||
|
│ │ ├── plaid-sync.processor.ts # @Processor — handles SYNC_INVESTMENTS jobs |
||||
|
│ │ └── plaid-sync.service.ts # Queue service — addJobToQueue |
||||
|
│ └── ...existing services unchanged |
||||
|
|
||||
|
apps/client/src/ |
||||
|
├── app/ |
||||
|
│ ├── components/ |
||||
|
│ │ └── header/ |
||||
|
│ │ ├── header.component.html # MODIFIED — nav restructure (move legacy items) |
||||
|
│ │ └── header.component.ts # MODIFIED — new menu arrays |
||||
|
│ ├── pages/ |
||||
|
│ │ └── fmv/ # NEW — FMV Dashboard page |
||||
|
│ │ ├── fmv-page.component.ts # Standalone component |
||||
|
│ │ ├── fmv-page.component.html # Hero total + account cards |
||||
|
│ │ └── fmv-page.routes.ts # Route definitions |
||||
|
│ └── services/ |
||||
|
│ └── plaid-link.service.ts # NEW — Angular service wrapping @plaid/link-initialize |
||||
|
|
||||
|
libs/common/src/lib/ |
||||
|
├── interfaces/ |
||||
|
│ └── plaid-item.interface.ts # NEW — shared PlaidItem type |
||||
|
├── config.ts # MODIFIED — PLAID_SYNC_QUEUE constant |
||||
|
└── permissions.ts # UNCHANGED (no permission changes needed) |
||||
|
|
||||
|
prisma/ |
||||
|
├── schema.prisma # MODIFIED — PlaidItem model, Account extensions |
||||
|
└── migrations/ |
||||
|
└── YYYYMMDD_add_plaid_item/ # NEW — migration for PlaidItem + Account fields |
||||
|
``` |
||||
|
|
||||
|
**Structure Decision**: Follows existing Nx monorepo conventions. Changes span 3 projects (`api`, `client`, `common`) which is within the 3-project maximum. New `plaid/` module in API mirrors existing module structure (e.g., `account/`, `portfolio/`). New `plaid-sync/` queue mirrors existing `data-gathering/` queue. New `fmv/` page follows existing page pattern (e.g., `accounts/`, `home/`). |
||||
|
|
||||
|
## Complexity Tracking |
||||
|
|
||||
|
> No violations — all gates pass. Feature uses 3 Nx projects (maximum) and follows established patterns. |
||||
|
|
||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because | |
||||
|
| --- | --- | --- | |
||||
|
| _(none)_ | — | — | |
||||
@ -0,0 +1,82 @@ |
|||||
|
# Quickstart: 009-fmv-plaid-drilldown |
||||
|
|
||||
|
## Prerequisites |
||||
|
|
||||
|
- Docker running (`gf-postgres-dev` on 5434, `gf-redis-dev` on 6379) |
||||
|
- Node.js 20+, npm |
||||
|
- Plaid sandbox credentials (for US4/US5 only; US1-US3 don't need Plaid) |
||||
|
|
||||
|
## Setup |
||||
|
|
||||
|
```bash |
||||
|
# Checkout branch |
||||
|
git checkout 009-fmv-plaid-drilldown |
||||
|
|
||||
|
# Install dependencies (if new packages added) |
||||
|
npm install |
||||
|
|
||||
|
# Run Prisma migration (after data model changes) |
||||
|
npx prisma migrate dev |
||||
|
|
||||
|
# Generate Prisma client |
||||
|
npx prisma generate |
||||
|
|
||||
|
# Start dev |
||||
|
npx nx serve api |
||||
|
npx nx serve client |
||||
|
``` |
||||
|
|
||||
|
## Environment Variables (for Plaid — US4/US5 only) |
||||
|
|
||||
|
Add to `.env`: |
||||
|
```env |
||||
|
# Plaid Integration |
||||
|
ENABLE_FEATURE_PLAID=true |
||||
|
PLAID_CLIENT_ID=<your-sandbox-client-id> |
||||
|
PLAID_SECRET=<your-sandbox-secret> |
||||
|
PLAID_ENV=sandbox |
||||
|
PLAID_ENCRYPTION_KEY=<32-byte-hex-key> |
||||
|
``` |
||||
|
|
||||
|
Generate encryption key: |
||||
|
```bash |
||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" |
||||
|
``` |
||||
|
|
||||
|
Get Plaid sandbox credentials from https://dashboard.plaid.com/developers/keys |
||||
|
|
||||
|
## Implementation Order |
||||
|
|
||||
|
1. **US1** — Nav restoration (client-only change, no API changes) |
||||
|
2. **US2** — FMV Dashboard page (new Angular components, reuses existing API) |
||||
|
3. **US3** — Asset Drill-Down (wire FMV cards to existing account-detail-dialog) |
||||
|
4. **US4** — Plaid Linking (Prisma migration + NestJS Plaid module + client Link flow) |
||||
|
5. **US5** — Plaid Sync (BullMQ queue + processor + cron + webhooks) |
||||
|
|
||||
|
## Verification |
||||
|
|
||||
|
### US1 — Nav |
||||
|
1. Login as USER role |
||||
|
2. Verify Analysis dropdown shows Overview, Holdings, Summary, Markets, Watchlist, FIRE, X-Ray |
||||
|
3. Navigate to each — all should render data |
||||
|
|
||||
|
### US2 — FMV Dashboard |
||||
|
1. Navigate to `/fmv` or FMV → Dashboard |
||||
|
2. Verify hero total matches sum of account values |
||||
|
3. Verify account cards show name, value, allocation % |
||||
|
|
||||
|
### US3 — Drill-Down |
||||
|
1. Click an account card in FMV Dashboard |
||||
|
2. Verify account detail dialog opens with Holdings tab |
||||
|
3. Verify holdings show symbol, quantity, cost basis, value, gain/loss |
||||
|
|
||||
|
### US4 — Plaid Link |
||||
|
1. Set `ENABLE_FEATURE_PLAID=true` in .env, restart API |
||||
|
2. Click "Link Account via Plaid" in FMV view |
||||
|
3. Use Plaid sandbox credentials: user_good / pass_good |
||||
|
4. Verify account created with holdings |
||||
|
|
||||
|
### US5 — Plaid Sync |
||||
|
1. After initial link, check `lastSyncedAt` on PlaidItem |
||||
|
2. Trigger manual refresh from UI |
||||
|
3. Verify sync job runs and timestamps update |
||||
@ -0,0 +1,202 @@ |
|||||
|
# 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 |
||||
|
|
||||
|
### Decision: Use `plaid` npm package (v41+) with `@plaid/link-initialize` for Angular client |
||||
|
|
||||
|
### 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 |
||||
@ -0,0 +1,174 @@ |
|||||
|
# Feature Specification: FMV Portfolio View with Plaid Account Linking & Asset Drill-Down |
||||
|
|
||||
|
**Feature Branch**: `009-fmv-plaid-drilldown` |
||||
|
**Created**: 2026-03-22 |
||||
|
**Status**: Draft |
||||
|
**Input**: User description: "Plaid account linking, FMV portfolio view, asset drill-down, restore admin-gated features for USER role" |
||||
|
|
||||
|
## User Scenarios & Testing _(mandatory)_ |
||||
|
|
||||
|
### User Story 1 - Restore Admin-Gated Features for All Users (Priority: P1) |
||||
|
|
||||
|
As a family office user with the `USER` role, I need access to the core portfolio features (Overview, Holdings, Summary, Markets, Watchlist, FIRE Calculator, X-Ray) that are currently hidden behind admin-only permissions. These features exist in the codebase under the "Legacy" nav group but are only visible when the user has `accessAdminControl` permission (ADMIN role). They should be available to all authenticated users. |
||||
|
|
||||
|
**Why this priority**: Without this, USER-role users on deployed environments (e.g., Railway) cannot access fundamental portfolio features they need. This is a navigation and permission fix — the features already work, they're just hidden. This unblocks all other stories. |
||||
|
|
||||
|
**Independent Test**: Log in as a USER-role account. Verify that Overview, Holdings, Summary, Watchlist, FIRE Calculator, and X-Ray are accessible from the main navigation without needing ADMIN role. |
||||
|
|
||||
|
**Acceptance Scenarios**: |
||||
|
|
||||
|
1. **Given** a user with `USER` role is logged in, **When** they view the navigation menu, **Then** they see all portfolio features (Overview, Holdings, Summary, Watchlist, FIRE, X-Ray) as accessible nav items — not hidden under an "Admin" dropdown |
||||
|
2. **Given** a user with `USER` role navigates to `/home/holdings`, **When** the page loads, **Then** the holdings table renders with all their portfolio positions, quantities, and performance data |
||||
|
3. **Given** a user with `USER` role navigates to `/portfolio/x-ray`, **When** the page loads, **Then** the X-Ray analysis renders showing portfolio concentration, regional exposure, and sector breakdown |
||||
|
4. **Given** a user with `USER` role navigates to `/portfolio/fire`, **When** the page loads, **Then** the FIRE calculator displays with their portfolio's withdrawal rate, savings rate, and projected timeline |
||||
|
5. **Given** a user with `USER` role navigates to `/home/watchlist`, **When** the page loads, **Then** the watchlist renders and allows adding/removing symbols |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### User Story 2 - FMV Portfolio Dashboard (Priority: P1) |
||||
|
|
||||
|
As a family office operator, I need a single "FMV" (Fair Market Value) view that aggregates the total value of all linked brokerage accounts and manually-tracked assets into one consolidated snapshot. This shows me my true net portfolio value across all accounts, with a breakdown by account showing each account's contribution. |
||||
|
|
||||
|
**Why this priority**: The core value proposition — seeing total FMV across all accounts in one place. The existing Accounts page already computes `totalValueInBaseCurrency` and per-account `valueInBaseCurrency`. This story packages that into a dedicated FMV nav section with a hero total and account-level cards. |
||||
|
|
||||
|
**Independent Test**: Navigate to the new FMV section. Verify the hero displays the aggregate value of all non-excluded accounts and that each account card shows its value, allocation percentage, and account name. |
||||
|
|
||||
|
**Acceptance Scenarios**: |
||||
|
|
||||
|
1. **Given** the user has 3 accounts with values of $500K, $300K, and $200K, **When** they navigate to the FMV view, **Then** the hero displays "$1,000,000" as total FMV and each account shows its contribution and allocation percentage |
||||
|
2. **Given** one account is marked as "excluded", **When** the FMV view loads, **Then** that account is omitted from the total and listed separately with an "Excluded" badge |
||||
|
3. **Given** accounts are denominated in different currencies (USD, EUR), **When** the FMV view loads, **Then** all values are converted to the user's base currency for aggregation, and each account shows both its native currency value and base currency equivalent |
||||
|
4. **Given** the user clicks on an account card, **When** the account detail opens, **Then** they see that account's holdings, activities, and balance history (existing account detail dialog behavior) |
||||
|
5. **Given** the user has no accounts, **When** they navigate to the FMV view, **Then** they see an empty state with a prompt to create or link an account |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### User Story 3 - Asset Drill-Down per Account (Priority: P1) |
||||
|
|
||||
|
As a family office operator, I need to drill into any brokerage account and see exactly which stocks, bonds, or crypto it holds — including number of shares, original cost basis, current market value, and performance over time. This information already exists in the system (computed from Orders + MarketData) but needs to be prominently accessible from the FMV view. |
||||
|
|
||||
|
**Why this priority**: This is the core drill-down experience. The existing account detail dialog already shows a Holdings tab with a holdings table filtered by account. This story ensures that flow is prominent and accessible directly from the FMV account cards, and that cost basis and per-holding performance are visible. |
||||
|
|
||||
|
**Independent Test**: From the FMV view, click an account card. Verify the account detail shows a Holdings tab listing all positions with symbol, quantity, cost basis, current value, and gain/loss. |
||||
|
|
||||
|
**Acceptance Scenarios**: |
||||
|
|
||||
|
1. **Given** a brokerage account holds 100 shares of NVDA bought at $50/share (cost basis $5,000) now worth $150/share, **When** the user drills into that account's holdings, **Then** they see: Symbol: NVDA, Quantity: 100, Cost Basis: $5,000, Current Value: $15,000, Gain: +$10,000 (+200%) |
||||
|
2. **Given** the user clicks on a specific holding row (e.g., NVDA), **When** the holding detail opens, **Then** they see a price chart, all buy/sell activity history, dividend history, sector/country breakdown, and performance metrics |
||||
|
3. **Given** an account has holdings in multiple asset classes (stocks, crypto, bonds), **When** the user views the account holdings, **Then** holdings are sortable by value, performance, name, or allocation percentage |
||||
|
4. **Given** the user has sold some shares of a position over time, **When** they view cost basis, **Then** the cost basis reflects only the remaining shares (adjusted for partial sales) |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### User Story 4 - Plaid Account Linking (Priority: P2) |
||||
|
|
||||
|
As a family office operator, I need to connect my brokerage accounts via Plaid so that account balances, holdings (positions), and transactions are automatically imported. This eliminates the need for manual data entry and keeps my FMV view current with real brokerage data. |
||||
|
|
||||
|
**Why this priority**: Plaid automates data ingestion. However, the FMV view and drill-down (US2 & US3) deliver value even with manually-entered data. Plaid adds automation on top. This is P2 because it requires a new third-party integration (Plaid API credentials, sandbox setup, webhook infrastructure). |
||||
|
|
||||
|
**Independent Test**: Click "Link Account" in the FMV view. Complete the Plaid Link flow with a sandbox institution. Verify the account appears with its holdings and balances auto-populated. |
||||
|
|
||||
|
**Acceptance Scenarios**: |
||||
|
|
||||
|
1. **Given** the user is on the FMV Accounts page, **When** they click "Link Account via Plaid", **Then** the Plaid Link modal opens showing the institution search |
||||
|
2. **Given** the user selects a brokerage (e.g., Vanguard sandbox), **When** they complete the auth flow, **Then** the system creates an Account record linked to the Plaid item, and a Platform record for the institution |
||||
|
3. **Given** a Plaid-linked account is created, **When** the system syncs investments, **Then** it creates SymbolProfile records for each holding, MarketData records for current prices, and Order records representing current positions |
||||
|
4. **Given** a Plaid-linked account exists, **When** the user returns to the FMV view, **Then** they see the account with its current balance and holdings automatically reflected — no manual entry needed |
||||
|
5. **Given** the Plaid connection expires or requires re-authentication, **When** the user visits the FMV view, **Then** they see a warning badge on the affected account with a "Reconnect" action that re-opens Plaid Link in update mode |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### User Story 5 - Plaid Ongoing Sync (Priority: P3) |
||||
|
|
||||
|
As a family office operator, I need my Plaid-linked accounts to stay current automatically. When I buy or sell stocks in my brokerage, those changes should be reflected in the system within a reasonable timeframe without manual intervention. |
||||
|
|
||||
|
**Why this priority**: P3 because initial linking (US4) provides the first snapshot. Ongoing sync requires webhook infrastructure and background job scheduling, which is additive complexity beyond the initial value. |
||||
|
|
||||
|
**Independent Test**: After initial Plaid linking, simulate a new transaction in Plaid sandbox. Trigger a sync. Verify the new position or balance change appears in the account holdings. |
||||
|
|
||||
|
**Acceptance Scenarios**: |
||||
|
|
||||
|
1. **Given** a Plaid-linked account, **When** the system runs a scheduled sync (or receives a webhook), **Then** new transactions since the last sync are imported as Order records |
||||
|
2. **Given** new investment holdings appear in Plaid (new stock purchase), **When** sync completes, **Then** the FMV view and account drill-down show the new position |
||||
|
3. **Given** a holding's quantity changes in Plaid (partial sale), **When** sync completes, **Then** the system creates a SELL Order record and the position quantity updates accordingly |
||||
|
4. **Given** the user manually triggers a "Refresh" on a Plaid-linked account, **When** the refresh completes, **Then** the account shows updated balances and holdings with a "Last synced: [timestamp]" indicator |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
### Edge Cases |
||||
|
|
||||
|
- What happens when Plaid returns a security that has no matching symbol in the system? System creates a MANUAL-type SymbolProfile with the Plaid-provided name, ticker, and CUSIP. |
||||
|
- What happens when Plaid returns a holding with no cost basis (e.g., transferred-in shares)? System displays "N/A" for cost basis and excludes from gain/loss calculations until the user manually provides it. |
||||
|
- What happens when the same stock is held across multiple accounts? Each account shows its own position independently; the FMV view aggregates across all accounts. |
||||
|
- How does the system handle Plaid rate limits? Queue sync operations using the existing BullMQ job infrastructure with exponential backoff. |
||||
|
- What if a user has both Plaid-linked and manually-managed accounts? Both types appear in the FMV view with a visual indicator (Plaid icon vs manual icon) of the data source. |
||||
|
- What happens when a Plaid-linked account is disconnected? The account and its historical data remain in the system (marked as "disconnected"), but no further syncs occur until reconnected. |
||||
|
|
||||
|
## Requirements _(mandatory)_ |
||||
|
|
||||
|
### Functional Requirements |
||||
|
|
||||
|
#### Navigation & Permission Restoration |
||||
|
|
||||
|
- **FR-001**: System MUST expose Overview, Holdings, Summary, Watchlist, FIRE Calculator, and X-Ray pages to users with the `USER` role (not just ADMIN) |
||||
|
- **FR-002**: System MUST reorganize navigation to include an "FMV" top-level nav item with sub-items: "Dashboard" (aggregate view) and "Accounts" (existing accounts list) |
||||
|
- **FR-003**: System MUST move the previously admin-gated legacy features into a user-accessible section of the navigation (e.g., under existing "Analysis" or as standalone items) |
||||
|
|
||||
|
#### FMV Dashboard |
||||
|
|
||||
|
- **FR-004**: System MUST display a total Fair Market Value that sums the value of all non-excluded accounts |
||||
|
- **FR-005**: System MUST break down total FMV by account, showing each account's name, platform, value, allocation percentage, and data source indicator (Plaid vs manual) |
||||
|
- **FR-006**: System MUST display per-account value as the sum of holdings market value plus cash balance |
||||
|
- **FR-007**: System MUST handle multi-currency aggregation by converting all account values to the user's base currency |
||||
|
|
||||
|
#### Asset Drill-Down |
||||
|
|
||||
|
- **FR-008**: System MUST display per-account holdings with: symbol, name, quantity, cost basis, current market value, unrealized gain/loss (absolute and percentage) |
||||
|
- **FR-009**: System MUST allow users to click any holding to view detailed information including price chart, activity history, dividends, and sector/country breakdown |
||||
|
- **FR-010**: System MUST support sorting holdings by any displayed column (value, performance, name, quantity, allocation) |
||||
|
|
||||
|
#### Plaid Integration |
||||
|
|
||||
|
- **FR-011**: System MUST integrate with Plaid Link to allow users to authenticate with their brokerage institutions |
||||
|
- **FR-012**: System MUST exchange Plaid public tokens for access tokens and store them securely (encrypted at rest) |
||||
|
- **FR-013**: System MUST sync investment holdings from Plaid, creating corresponding symbol profiles, market data, and activity records |
||||
|
- **FR-014**: System MUST sync account balances from Plaid and update the account's balance field |
||||
|
- **FR-015**: System MUST provide a mechanism to re-authenticate expired Plaid connections (update mode) |
||||
|
- **FR-016**: System MUST map Plaid security types to the existing asset class and sub-class categories |
||||
|
|
||||
|
#### Ongoing Sync |
||||
|
|
||||
|
- **FR-017**: System MUST support scheduled background syncs for Plaid-linked accounts using the existing job queue infrastructure |
||||
|
- **FR-018**: System MUST track the last sync timestamp per Plaid-linked account and display it to the user |
||||
|
- **FR-019**: System MUST allow users to manually trigger an on-demand sync for any Plaid-linked account |
||||
|
|
||||
|
### Key Entities |
||||
|
|
||||
|
- **PlaidItem**: Represents a Plaid Link connection to an institution. Key attributes: access token (encrypted), institution ID, institution name, connection status, consent expiration date. Relates to one User and one or more Accounts. |
||||
|
- **Account** (extended): Existing account entity gains an optional relationship to a PlaidItem and a data source indicator (PLAID vs MANUAL). Existing fields (balance, currency, platform) are populated automatically from Plaid data. |
||||
|
- **SymbolProfile** (existing): Holdings from Plaid map to SymbolProfile records. System matches by ticker/CUSIP or creates new MANUAL-type profiles for unmatched securities. |
||||
|
- **Order** (existing): Plaid investment transactions become Order records (BUY, SELL, DIVIDEND). Source is tracked to distinguish Plaid-imported vs manually-entered activities. |
||||
|
|
||||
|
## Success Criteria _(mandatory)_ |
||||
|
|
||||
|
### Measurable Outcomes |
||||
|
|
||||
|
- **SC-001**: Users with `USER` role can access all portfolio features (Overview, Holdings, Summary, Watchlist, FIRE, X-Ray) without requiring ADMIN role promotion |
||||
|
- **SC-002**: The FMV Dashboard displays the aggregate value of all linked accounts within 3 seconds of page load |
||||
|
- **SC-003**: Users can drill from the FMV view into any account's individual holdings within 2 clicks |
||||
|
- **SC-004**: Each holding displays cost basis, current value, and gain/loss — matching what the user would see in their brokerage account |
||||
|
- **SC-005**: Users can complete the Plaid Link flow (search institution → authenticate → account created) in under 2 minutes |
||||
|
- **SC-006**: After initial Plaid sync, all investment holdings from the brokerage appear in the account's drill-down view with accurate quantities and current values |
||||
|
- **SC-007**: Plaid-linked accounts reflect updated balances and holdings within 24 hours of changes occurring at the brokerage |
||||
|
- **SC-008**: A user with 5 linked accounts totaling 50 holdings sees a complete FMV snapshot load in under 5 seconds |
||||
|
|
||||
|
## Assumptions |
||||
|
|
||||
|
- Plaid API credentials (client ID and secret) will be configured as environment variables. Sandbox credentials will be used during development; production credentials for deployment. |
||||
|
- The existing Platform model is sufficient to represent Plaid institutions (name + URL). No new model needed for institutions. |
||||
|
- The existing Order + MarketData + SymbolProfile pipeline is the right mechanism for representing Plaid holdings — no separate "Position" model is needed. |
||||
|
- Plaid's "Investments" product (which includes holdings and transactions) is the primary product needed. "Transactions" product (bank transactions) is out of scope for this initiative. |
||||
|
- The existing account detail dialog UI (with Holdings, Activities, Cash Balances tabs) provides the drill-down UX. No new detail page is needed — just better entry points from the FMV view. |
||||
|
- The existing BullMQ job queue (already used for portfolio snapshots) will be reused for Plaid sync scheduling. |
||||
|
- Account types (SECURITIES, CHECKING, etc.) from Plaid can be stored as a new field on the Account model or as metadata — exact storage approach is a planning-phase decision. |
||||
|
- The Markets page (currently admin-only) requires the market data read permission which USER does not have. This page may need its own permission or the permission may need to be granted to USER role. The exact approach will be determined during planning. |
||||
@ -0,0 +1,57 @@ |
|||||
|
# Tasks: 009-fmv-plaid-drilldown |
||||
|
|
||||
|
**Branch**: `009-fmv-plaid-drilldown` | **Date**: 2026-03-22 |
||||
|
|
||||
|
## Phase 1: Setup |
||||
|
|
||||
|
- [X] T-001: Create `.gitignore` / `.dockerignore` verification [P] |
||||
|
- [X] T-002: Add Plaid env vars to environment interface and configuration service (ENABLE_FEATURE_PLAID, PLAID_CLIENT_ID, PLAID_SECRET, PLAID_ENV, PLAID_ENCRYPTION_KEY) |
||||
|
|
||||
|
## Phase 2: US1 — Restore Admin-Gated Features (P1) |
||||
|
|
||||
|
- [X] T-010: Move legacy menu items out of admin gate in header desktop nav |
||||
|
- [X] T-011: Move legacy menu items out of admin gate in header mobile nav |
||||
|
- [X] T-012: Add "Analysis" nav menu group for legacy items (desktop) |
||||
|
- [X] T-013: Add "Analysis" nav menu group for legacy items (mobile) |
||||
|
|
||||
|
## Phase 3: US2 — FMV Dashboard (P1) |
||||
|
|
||||
|
- [X] T-020: Create FMV dashboard page component and route |
||||
|
- [X] T-021: Implement FMV hero total display |
||||
|
- [X] T-022: Implement account cards with value, allocation, platform |
||||
|
- [X] T-023: Add FMV nav menu group to header (desktop + mobile) |
||||
|
- [X] T-024: Handle empty state and excluded accounts |
||||
|
|
||||
|
## Phase 4: US3 — Asset Drill-Down (P1) |
||||
|
|
||||
|
- [X] T-030: Wire FMV account cards to existing account-detail-dialog |
||||
|
- [X] T-031: Verify holdings tab shows cost basis, value, gain/loss per holding |
||||
|
|
||||
|
## Phase 5: US4 — Plaid Account Linking (P2) |
||||
|
|
||||
|
- [X] T-040: Add PlaidItem model to Prisma schema + Account extensions |
||||
|
- [X] T-041: Run Prisma migration |
||||
|
- [X] T-042: Add PlaidItem shared interface in @ghostfolio/common |
||||
|
- [X] T-043: Install plaid npm package + @plaid/link-initialize |
||||
|
- [X] T-044: Create PlaidModule (module, controller, service) in API |
||||
|
- [X] T-045: Implement encryption service for access tokens |
||||
|
- [X] T-046: Implement link-token creation endpoint |
||||
|
- [X] T-047: Implement exchange-token endpoint (create PlaidItem + Accounts) |
||||
|
- [X] T-048: Implement initial holdings sync on account creation |
||||
|
- [X] T-049: Create Angular PlaidLinkService wrapping @plaid/link-initialize |
||||
|
- [X] T-050: Add "Link Account via Plaid" button to FMV dashboard |
||||
|
- [X] T-051: Implement Plaid items list endpoint |
||||
|
- [X] T-052: Add Plaid status indicators to FMV account cards |
||||
|
- [X] T-053: Implement re-auth (update mode) link-token endpoint |
||||
|
- [X] T-054: Implement delete/disconnect PlaidItem endpoint |
||||
|
|
||||
|
## Phase 6: US5 — Plaid Ongoing Sync (P3) |
||||
|
|
||||
|
- [X] T-060: Add PLAID_SYNC_QUEUE constant to config.ts |
||||
|
- [X] T-061: Create PlaidSyncModule (module, processor, service) |
||||
|
- [X] T-062: Implement sync processor (fetch holdings + transactions, create Orders) |
||||
|
- [X] T-063: Add manual sync trigger endpoint |
||||
|
- [X] T-064: Add cron job for daily Plaid sync |
||||
|
- [X] T-065: Implement webhook receiver endpoint |
||||
|
- [X] T-066: Add "Refresh" button and last-synced indicator to UI |
||||
|
- [X] T-067: Register PlaidModule and PlaidSyncModule in app.module.ts |
||||
Loading…
Reference in new issue