-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Plaid bank integration with automated transaction sync #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b3a8d6e
f16e8b4
59b0791
18835e3
a06569f
2e23a20
414575d
c594e86
4f22a4d
e132725
959a817
b4e6c52
86a2719
412ee19
ca5e907
8e6b389
b7fc5ff
53bd103
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| node_modules/ | ||
| .env |
| 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(), | ||
| }); | ||
|
|
||
| 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
|
||
| 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 }; | ||
| }); | ||
There was a problem hiding this comment.
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
accessTokenis stored "encrypted in Firestore", but the code writes it directly asaccessToken: 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.