feat: Plaid bank integration with automated transaction sync#4
feat: Plaid bank integration with automated transaction sync#4nayyarsan wants to merge 18 commits into
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…hTransactions, revokeToken)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…chema v4) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix PlaidLink.open() API — plaid_flutter 5.1.1 uses create(configuration:) then open() separately.
- Fix stream listener leak in PlaidService.connectAccount (cancel before re-subscribe) - Add pairedAccountId to PendingReviewTransactions (schema v5) so ReviewScreen can insert both transfer legs correctly with toAccountId populated - disconnect() now calls deleteAllPlaidAccounts() instead of single row delete, matching Plaid's one-token-per-item model
- Remove unnecessary cast in plaid_service.dart - Remove unused imports in bank_sync_screen and review_screen - Add emulator ports to firebase.json for local development
Node 18 was decommissioned on 2025-10-30. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds Plaid-based bank connectivity and an automated (non-blocking) transaction sync pipeline to the MoneyInSight Flutter app, backed by Firebase Cloud Functions for all Plaid API calls and new Drift tables to track linked accounts + “pending review” items.
Changes:
- Introduces Plaid Link connect/disconnect flow (Flutter) and Plaid API bridge (Firebase Cloud Functions).
- Adds local sync pipeline: fetch → dedupe → transfer-detect → insert/queue-for-review, plus UI surfacing of review items (banner + review screen).
- Updates Drift schema (v5) with
plaid_accountsandpending_review_transactions, plus a newPlaidDao.
Reviewed changes
Copilot reviewed 21 out of 25 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| test/core/plaid/transfer_detector_test.dart | Unit tests for transfer pairing windows (auto-tag vs ambiguous). |
| test/core/plaid/transaction_deduplicator_test.dart | Unit tests for exact/near duplicate detection and skip behavior. |
| pubspec.yaml | Adds Plaid Link + Cloud Functions Flutter dependencies. |
| pubspec.lock | Locks new dependencies and transitive packages. |
| lib/features/shell/main_shell.dart | Triggers background sync on launch; shows MaterialBanner for pending review count. |
| lib/features/settings/settings_screen.dart | Adds Settings entry point for “Bank Accounts”. |
| lib/features/bank_sync/review_screen.dart | UI to review/resolve flagged duplicates/transfers. |
| lib/features/bank_sync/bank_sync_screen.dart | Connect/disconnect UI for linked bank accounts. |
| lib/features/bank_sync/bank_sync_providers.dart | Riverpod notifier/provider wiring for sync + pending-review count stream. |
| lib/core/plaid/transfer_detector.dart | Pure transfer detection logic. |
| lib/core/plaid/transaction_deduplicator.dart | Pure deduplication/flagging logic. |
| lib/core/plaid/plaid_service.dart | Client-side Plaid Link orchestration + callable-functions fetch/disconnect. |
| lib/core/plaid/plaid_models.dart | Plaid transaction/account models + sync helper structs. |
| lib/core/database/tables.dart | Adds PlaidAccounts + PendingReviewTransactions tables. |
| lib/core/database/database.g.dart | Regenerated Drift code for new tables/DAO (generated). |
| lib/core/database/database.dart | Registers new tables/DAO and bumps schema version/migrations. |
| lib/core/database/daos/plaid_dao.dart | New DAO for Plaid accounts + pending-review queries/mutations. |
| lib/core/database/daos/plaid_dao.g.dart | Generated mixin for PlaidDao (generated). |
| functions/package.json | Adds Cloud Functions Node project with Plaid SDK dependency. |
| functions/index.js | Implements callable functions: link token, token exchange, fetch txns, revoke token. |
| functions/.gitignore | Ignores node_modules and .env for functions. |
| firebase.json | Adds Functions config + emulator ports. |
| android/app/src/main/AndroidManifest.xml | Registers Plaid Link activity for Android. |
| .gitignore | Adds .worktrees/ ignore. |
| PlaidDao get plaidDao => PlaidDao(this); | ||
|
|
There was a problem hiding this comment.
AppDatabase defines PlaidDao get plaidDao => PlaidDao(this);, but _$AppDatabase (generated) already provides a plaidDao member. This will conflict/override the generated accessor and can cause build errors or create a new DAO instance each access. Remove this getter and use the generated plaidDao instead (or rename if you need a custom accessor).
| PlaidDao get plaidDao => PlaidDao(this); |
| if (from < 4) { | ||
| await m.createTable(plaidAccounts); | ||
| await m.createTable(pendingReviewTransactions); | ||
| } | ||
| if (from < 5) { | ||
| await m.addColumn( | ||
| pendingReviewTransactions, | ||
| pendingReviewTransactions.pairedAccountId, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Migration logic will attempt to add pendingReviewTransactions.pairedAccountId whenever from < 5. But when migrating from from < 4, m.createTable(pendingReviewTransactions) uses the current table definition (which already includes pairedAccountId), so addColumn will try to add an existing column and fail. Gate the addColumn to only run when upgrading from schema 4 (e.g., if (from == 4)).
| for (final tx in transferResult.regularTransactions) { | ||
| final internalAccountId = accountMap[tx.plaidAccountId]!; | ||
| final type = tx.amountCents > 0 ? 'expense' : 'income'; | ||
| await db.transactionsDao.insertTransaction(TransactionsCompanion( | ||
| accountId: Value(internalAccountId), | ||
| amountCents: Value(tx.amountCents.abs()), | ||
| date: Value(tx.date), | ||
| payee: Value(tx.payee), | ||
| memo: Value(tx.memo), | ||
| type: Value(type), | ||
| importedFrom: const Value('plaid'), | ||
| )); |
There was a problem hiding this comment.
Regular transaction insertion is using amountCents: tx.amountCents.abs() and type = tx.amountCents > 0 ? 'expense' : 'income'. In this codebase, expenses are stored as negative cents and income as positive (see AddTransactionScreen), so this will record all Plaid expenses as positive and break balances/budgets. Convert Plaid sign → internal sign (e.g., store -abs for expenses and +abs for income) while keeping type consistent.
| ); | ||
| final existingSummaries = existingTxs.map((t) => ExistingTransactionSummary( | ||
| internalAccountId: t.accountId, | ||
| amountCents: t.amountCents.abs(), |
There was a problem hiding this comment.
Deduplication currently compares amounts using absolute values only (ExistingTransactionSummary.amountCents is built with t.amountCents.abs()), so an existing income and an incoming expense with the same payee/date/abs(amount) could be treated as an exact duplicate and skipped (and similarly for near-duplicates). Include direction in the comparison (store signed amounts in ExistingTransactionSummary, or include type/sign in the match conditions).
| amountCents: t.amountCents.abs(), | |
| amountCents: t.amountCents, |
| // 2. Build plaidAccountId → internalAccountId map from PlaidAccounts table | ||
| final plaidAccounts = await db.plaidDao.getAllPlaidAccounts(); | ||
| final accountMap = { | ||
| for (final pa in plaidAccounts) | ||
| pa.plaidAccountId: pa.internalAccountId, | ||
| }; | ||
|
|
There was a problem hiding this comment.
accountMap is built from plaid_accounts, but this PR doesn’t appear to ever insert rows into plaid_accounts (no insertPlaidAccount usage). As a result, TransactionDeduplicator will treat every incoming Plaid transaction as an unknown account and skip them, so sync will insert nothing. Add logic (likely on successful connect or first fetch) to create internal accounts and/or persist Plaid accounts into plaid_accounts before running dedup/transfer detection.
| /// Maps a Plaid account_id to an internal account row. | ||
| /// Created when user connects a bank account via Plaid Link. | ||
| class PlaidAccounts extends Table { | ||
| IntColumn get id => integer().autoIncrement()(); | ||
| TextColumn get plaidAccountId => text()(); | ||
| IntColumn get internalAccountId => integer().references(Accounts, #id)(); | ||
| TextColumn get institutionName => text()(); | ||
| TextColumn get mask => text().withLength(min: 0, max: 4)(); | ||
| DateTimeColumn get syncedAt => dateTime().nullable()(); | ||
| } |
There was a problem hiding this comment.
The Drift table PlaidAccounts will generate a data class named PlaidAccount, which collides conceptually (and can collide in imports) with the Plaid API model PlaidAccount in lib/core/plaid/plaid_models.dart. Consider renaming the table/data class via @DataClassName(...) (e.g., LinkedPlaidAccount) to avoid ambiguous imports and confusion.
| IntColumn get internalAccountId => integer().references(Accounts, #id)(); | ||
| TextColumn get institutionName => text()(); | ||
| TextColumn get mask => text().withLength(min: 0, max: 4)(); | ||
| DateTimeColumn get syncedAt => dateTime().nullable()(); |
There was a problem hiding this comment.
plaidAccountId in PlaidAccounts has no uniqueness constraint, so the same Plaid account can be inserted multiple times (which would break accountMap and UI lists). Add a unique key/index on plaidAccountId (similar to how other tables use uniqueKeys) and/or use an upsert strategy in the DAO.
| DateTimeColumn get syncedAt => dateTime().nullable()(); | |
| DateTimeColumn get syncedAt => dateTime().nullable()(); | |
| @override | |
| List<Set<Column>> get uniqueKeys => [ | |
| {plaidAccountId}, | |
| ]; |
| await admin.firestore() | ||
| .collection('users') | ||
| .doc(context.auth.uid) | ||
| .collection('plaid') | ||
| .doc('connection') | ||
| .set({ | ||
| accessToken: response.data.access_token, | ||
| itemId: response.data.item_id, | ||
| connectedAt: admin.firestore.FieldValue.serverTimestamp(), | ||
| }); |
There was a problem hiding this comment.
The comment says the Plaid accessToken is stored "encrypted in Firestore", but the code writes it directly as accessToken: response.data.access_token. Unless you’re doing explicit envelope encryption elsewhere (not shown here), this is plaintext storage. Either implement encryption (e.g., Cloud KMS / Secret Manager + ciphertext in Firestore) or update the comment and ensure Firestore rules prevent client reads.
| const response = await plaidClient.transactionsGet({ | ||
| access_token: accessToken, | ||
| start_date: sinceDate, | ||
| end_date: today, | ||
| options: { count: 500, offset: 0 }, | ||
| }); | ||
|
|
There was a problem hiding this comment.
fetchTransactions uses transactionsGet with options: { count: 500, offset: 0 } but never paginates, so accounts with >500 transactions in the requested window will silently miss data. Loop with increasing offset until you’ve fetched total_transactions (or until a page returns fewer than count) and concatenate results.
| final absAmount = tx.amountCents.abs(); | ||
|
|
||
| // Exact match: same payee, amount, date, account → silent skip | ||
| final isExact = existing.any((e) => | ||
| e.internalAccountId == internalId && | ||
| e.amountCents == absAmount && | ||
| e.payee == tx.payee && | ||
| _sameDay(e.date, tx.date)); | ||
|
|
||
| if (isExact) { | ||
| skipped++; | ||
| continue; | ||
| } | ||
|
|
||
| // Near match: same amount + account, date within ±3 days (but not same day), different payee → flag | ||
| final isNear = existing.any((e) { | ||
| final daysDiff = e.date.difference(tx.date).inDays.abs(); | ||
| return e.internalAccountId == internalId && | ||
| e.amountCents == absAmount && | ||
| e.payee != tx.payee && | ||
| daysDiff > 0 && | ||
| daysDiff <= 3; | ||
| }); |
There was a problem hiding this comment.
TransactionDeduplicator normalizes incoming amounts with final absAmount = tx.amountCents.abs() and matches against existing.amountCents, which makes deduplication direction-agnostic. This can incorrectly treat an income and an expense of the same absolute amount/payee/date/account as duplicates. Consider preserving sign/direction in the comparison (e.g., compare signed cents or include a derived isExpense/type field in ExistingTransactionSummary).
Summary
Key Design Decisions
plaid_accounts+pending_review_transactionstables (Drift)Test Plan
🤖 Generated with Claude Code