Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b3a8d6e
chore: ignore .worktrees directory
nayyarcoder Apr 4, 2026
f16e8b4
feat: scaffold Firebase Functions project for Plaid integration
nayyarcoder Apr 4, 2026
59b0791
feat: add Plaid Cloud Functions (createLinkToken, exchangeToken, fetc…
nayyarcoder Apr 4, 2026
18835e3
feat: add plaid_flutter and cloud_functions dependencies
nayyarcoder Apr 5, 2026
a06569f
feat: add Plaid data models (PlaidTransaction, FlaggedPlaidTransactio…
nayyarcoder Apr 5, 2026
2e23a20
feat: add PlaidAccounts and PendingReviewTransactions Drift tables (s…
nayyarcoder Apr 5, 2026
414575d
feat: add PlaidDao for plaid_accounts and pending_review_transactions
nayyarcoder Apr 5, 2026
c594e86
feat: add TransactionDeduplicator with tests
nayyarcoder Apr 5, 2026
4f22a4d
feat: add TransferDetector with tests
nayyarcoder Apr 5, 2026
e132725
feat: add PlaidService (Link OAuth flow + fetchTransactions)
nayyarcoder Apr 5, 2026
959a817
feat: add BankSyncNotifier and PlaidSyncService orchestrator
nayyarcoder Apr 5, 2026
b4e6c52
feat: add BankSyncScreen for connecting/disconnecting bank accounts
nayyarcoder Apr 5, 2026
86a2719
feat: add ReviewScreen for flagged Plaid transactions
nayyarcoder Apr 5, 2026
412ee19
feat: add Bank Accounts entry in Settings
nayyarcoder Apr 5, 2026
ca5e907
feat: auto-sync Plaid on app launch + show review banner in MainShell
nayyarcoder Apr 5, 2026
8e6b389
fix: address code review issues in Plaid integration
nayyarcoder Apr 5, 2026
b7fc5ff
chore: fix analyzer warnings + add emulator config to firebase.json
nayyarcoder Apr 5, 2026
53bd103
Upgrade Firebase Functions runtime to Node.js 20
nayyarcoder Apr 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,6 @@ google-services.json

# Web platform — disabled (Drift sqlite3 FFI is not web-compatible)
/web/

# Git worktrees
.worktrees/
3 changes: 3 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<activity
android:name="com.plaid.link.LinkActivity"
android:exported="false" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
Expand Down
17 changes: 17 additions & 0 deletions firebase.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"functions": {
"source": "functions",
"runtime": "nodejs20"
},
"emulators": {
"functions": {
"port": 5001
},
"firestore": {
"port": 8080
},
"ui": {
"enabled": false
}
}
}
2 changes: 2 additions & 0 deletions functions/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
.env
150 changes: 150 additions & 0 deletions functions/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const { PlaidApi, PlaidEnvironments, Configuration } = require('plaid');

admin.initializeApp();

const plaidConfig = new Configuration({
basePath: process.env.PLAID_ENV === 'sandbox'
? PlaidEnvironments.sandbox
: PlaidEnvironments.development,
baseOptions: {
headers: {
'PLAID-CLIENT-ID': process.env.PLAID_CLIENT_ID,
'PLAID-SECRET': process.env.PLAID_SECRET,
},
},
});
const plaidClient = new PlaidApi(plaidConfig);

/**
* Creates a Plaid Link token to initiate the OAuth flow in the Flutter app.
* The link token is short-lived (30 min) and single-use.
*/
exports.createLinkToken = functions.https.onCall(async (data, context) => {
if (!context.auth) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in');
}

const response = await plaidClient.linkTokenCreate({
user: { client_user_id: context.auth.uid },
client_name: 'MoneyInSight',
products: ['transactions'],
country_codes: ['US'],
language: 'en',
});

return { linkToken: response.data.link_token };
});

/**
* Exchanges a Plaid public_token (from Link success) for an access_token.
* Stores the access_token encrypted in Firestore — never returned to the client.
*/
exports.exchangeToken = functions.https.onCall(async (data, context) => {
if (!context.auth) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in');
}

const { publicToken } = data;
if (!publicToken) {
throw new functions.https.HttpsError('invalid-argument', 'publicToken required');
}

const response = await plaidClient.itemPublicTokenExchange({
public_token: publicToken,
});

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(),
});
Comment on lines +58 to +67
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.

return { success: true };
});

/**
* Fetches transactions from Plaid since the given date.
* Returns raw transaction and account data for the Flutter app to process.
*/
exports.fetchTransactions = functions.https.onCall(async (data, context) => {
if (!context.auth) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in');
}

const plaidDoc = await admin.firestore()
.collection('users')
.doc(context.auth.uid)
.collection('plaid')
.doc('connection')
.get();

if (!plaidDoc.exists) {
return { transactions: [], accounts: [], connected: false };
}

const { accessToken } = plaidDoc.data();

// Default to 30 days back if no since date provided
const sinceDate = data.since
?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const today = new Date().toISOString().split('T')[0];

const response = await plaidClient.transactionsGet({
access_token: accessToken,
start_date: sinceDate,
end_date: today,
options: { count: 500, offset: 0 },
});

Comment on lines +99 to +105
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.
return {
connected: true,
transactions: response.data.transactions.map((t) => ({
plaidId: t.transaction_id,
plaidAccountId: t.account_id,
// Plaid: positive = money out (debit), negative = money in (credit)
amountCents: Math.round(t.amount * 100),
date: t.date,
payee: t.merchant_name || t.name,
memo: t.name !== t.merchant_name ? t.name : null,
})),
accounts: response.data.accounts.map((a) => ({
plaidAccountId: a.account_id,
name: a.name,
mask: a.mask,
type: a.type,
subtype: a.subtype,
})),
};
});

/**
* Revokes Plaid access and removes stored credentials from Firestore.
* Called when user disconnects their bank account.
*/
exports.revokeToken = functions.https.onCall(async (data, context) => {
if (!context.auth) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in');
}

const plaidRef = admin.firestore()
.collection('users')
.doc(context.auth.uid)
.collection('plaid')
.doc('connection');

const plaidDoc = await plaidRef.get();
if (!plaidDoc.exists) return { success: true };

const { accessToken } = plaidDoc.data();
await plaidClient.itemRemove({ access_token: accessToken });
await plaidRef.delete();

return { success: true };
});
Loading
Loading