mirror of https://github.com/ghostfolio/ghostfolio
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
5.5 KiB
5.5 KiB
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)
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)
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):
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
commentfield (e.g.,"plaid-sync:{transactionId}") to avoid re-importing
MarketData
- Current prices from Plaid sync stored as MarketData entries
- Used by
PortfolioCalculatorfor 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 normallyerror = 'ITEM_LOGIN_REQUIRED'→ Needs re-authenticationerror = 'PENDING_EXPIRATION'→ Consent expiring soonerror = 'ITEM_REMOVED'→ Item was removed at institutionconsentExpiresAt < 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
- PlaidItem.accessToken: Must be non-empty, encrypted before storage
- PlaidItem.itemId: Must be unique across all users (Plaid guarantees uniqueness)
- Account.plaidItemId: If set, the referenced PlaidItem must belong to the same userId
- Account.plaidAccountId: If set, plaidItemId must also be set
- Order from Plaid: Comment field with
plaid-sync:prefix acts as idempotency key — prevents duplicate imports on re-sync