Skip to content

feat: Plaid bank integration with automated transaction sync#4

Open
nayyarsan wants to merge 18 commits into
mainfrom
feature/plaid-integration
Open

feat: Plaid bank integration with automated transaction sync#4
nayyarsan wants to merge 18 commits into
mainfrom
feature/plaid-integration

Conversation

@nayyarsan
Copy link
Copy Markdown
Owner

Summary

  • Adds Plaid Link OAuth flow to connect bank accounts (Bank of America + others)
  • Firebase Cloud Functions handle all Plaid API calls (secrets never in Flutter app)
  • Auto-syncs transactions on app launch (non-blocking); deduplicates and detects transfers
  • Flagged items (near-duplicate/transfer review) surface as MaterialBanner in MainShell

Key Design Decisions

  • Plaid secret key stays server-side via Firebase Cloud Functions
  • Transfer detection: same-day/next-day → auto-tag; 2-3 day gap → flag for review
  • Deduplication: exact match → skip; near match (±3 days, same amount+account) → flag
  • Schema v5: plaid_accounts + pending_review_transactions tables (Drift)

Test Plan

  • Connect bank account via Settings → Bank Accounts
  • Verify transactions sync on app launch
  • Verify duplicate transactions are skipped
  • Verify transfer pairs are auto-tagged
  • Verify flagged items appear in review banner
  • Disconnect bank account and verify cleanup

🤖 Generated with Claude Code

nayyarcoder and others added 18 commits April 4, 2026 14:51
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
Copilot AI review requested due to automatic review settings April 6, 2026 04:46
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_accounts and pending_review_transactions, plus a new PlaidDao.

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.

Comment on lines +45 to +46
PlaidDao get plaidDao => PlaidDao(this);

Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
PlaidDao get plaidDao => PlaidDao(this);

Copilot uses AI. Check for mistakes.
Comment on lines +62 to +71
if (from < 4) {
await m.createTable(plaidAccounts);
await m.createTable(pendingReviewTransactions);
}
if (from < 5) {
await m.addColumn(
pendingReviewTransactions,
pendingReviewTransactions.pairedAccountId,
);
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)).

Copilot uses AI. Check for mistakes.
Comment on lines +95 to +106
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'),
));
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
);
final existingSummaries = existingTxs.map((t) => ExistingTransactionSummary(
internalAccountId: t.accountId,
amountCents: t.amountCents.abs(),
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
amountCents: t.amountCents.abs(),
amountCents: t.amountCents,

Copilot uses AI. Check for mistakes.
Comment on lines +49 to +55
// 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,
};

Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +133
/// 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()();
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
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()();
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
DateTimeColumn get syncedAt => dateTime().nullable()();
DateTimeColumn get syncedAt => dateTime().nullable()();
@override
List<Set<Column>> get uniqueKeys => [
{plaidAccountId},
];

Copilot uses AI. Check for mistakes.
Comment thread functions/index.js
Comment on lines +58 to +67
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(),
});
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread functions/index.js
Comment on lines +99 to +105
const response = await plaidClient.transactionsGet({
access_token: accessToken,
start_date: sinceDate,
end_date: today,
options: { count: 500, offset: 0 },
});

Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +38 to +60
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;
});
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants