From 415150d58e1c64e90b6b1dd4400921d3b2352583 Mon Sep 17 00:00:00 2001 From: Max Koon <22125083+k2on@users.noreply.github.com> Date: Wed, 15 Oct 2025 10:53:37 -0400 Subject: [PATCH] feat: query plaid transactions --- api/package.json | 3 +- api/src/zero.ts | 89 ++++++++++++++++++++++++++++++++- app/_layout.tsx | 1 + app/index.tsx | 57 ++++++++++++--------- app/settings.tsx | 41 +++++++++++++++ pnpm-lock.yaml | 71 ++++++++++++++++++++++++++ shared/src/db/schema/private.ts | 7 +++ shared/src/db/schema/public.ts | 11 +++- shared/src/mutators.ts | 4 +- shared/src/queries.ts | 9 +++- shared/src/schema.ts | 4 +- shared/src/zero-schema.gen.ts | 57 +++++++++++++++++++++ 12 files changed, 322 insertions(+), 32 deletions(-) create mode 100644 app/settings.tsx diff --git a/api/package.json b/api/package.json index 232a18e..29c1c5e 100644 --- a/api/package.json +++ b/api/package.json @@ -9,7 +9,8 @@ "@hono/node-server": "^1.19.5", "@money/shared": "link:../shared", "better-auth": "^1.3.27", - "hono": "^4.9.12" + "hono": "^4.9.12", + "plaid": "^39.0.0" }, "devDependencies": { "@types/node": "^24.7.2" diff --git a/api/src/zero.ts b/api/src/zero.ts index ec01ca6..d1eca5f 100644 --- a/api/src/zero.ts +++ b/api/src/zero.ts @@ -1,5 +1,6 @@ import { type ReadonlyJSONValue, + type Transaction, withValidation, } from "@rocicorp/zero"; import { @@ -11,13 +12,31 @@ import { PostgresJSConnection } from '@rocicorp/zero/pg'; import postgres from 'postgres'; import { createMutators as createMutatorsShared, + isLoggedIn, queries, schema, type Mutators, + type Schema, } from "@money/shared"; import type { AuthData } from "@money/shared/auth"; import { getHono } from "./hono"; +import { Configuration, CountryCode, PlaidApi, PlaidEnvironments, Products } from "plaid"; +import { randomUUID } from "crypto"; import { db } from "./db"; +import { plaidAccessTokens, plaidLink, transaction } from "@money/shared/db"; +import { eq } from "drizzle-orm"; + + +const configuration = new Configuration({ + basePath: PlaidEnvironments.production, + baseOptions: { + headers: { + 'PLAID-CLIENT-ID': process.env.PLAID_CLIENT_ID, + 'PLAID-SECRET': process.env.PLAID_SECRET, + } + } +}); +const plaidClient = new PlaidApi(configuration); const processor = new PushProcessor( @@ -27,6 +46,8 @@ const processor = new PushProcessor( ), ); +type Tx = Transaction; + const createMutators = (authData: AuthData | null) => { const mutators = createMutatorsShared(authData); return { @@ -34,7 +55,73 @@ const createMutators = (authData: AuthData | null) => { link: { ...mutators.link, async create() { - console.log("Here is my function running on the server!!!"); + isLoggedIn(authData); + console.log("Creating Link token"); + const r = await plaidClient.linkTokenCreate({ + user: { + client_user_id: authData.user.id, + }, + client_name: "Koon Money", + language: "en", + products: [Products.Transactions], + country_codes: [CountryCode.Us], + hosted_link: {} + }); + console.log("Result", r); + const { link_token, hosted_link_url } = r.data; + + if (!hosted_link_url) throw Error("No link in response"); + + await db.insert(plaidLink).values({ + id: randomUUID() as string, + user_id: authData.user.id, + link: hosted_link_url, + token: link_token, + }); + }, + async get(_, { link_token }) { + isLoggedIn(authData); + + const linkResp = await plaidClient.linkTokenGet({ + link_token, + }); + if (!linkResp) throw Error("No link respo"); + console.log(JSON.stringify(linkResp.data, null, 4)); + const publicToken = linkResp.data.link_sessions?.at(0)?.results?.item_add_results.at(0)?.public_token; + + if (!publicToken) throw Error("No public token"); + const { data } = await plaidClient.itemPublicTokenExchange({ + public_token: publicToken, + }) + + await db.insert(plaidAccessTokens).values({ + id: randomUUID(), + userId: authData.user.id, + token: data.access_token, + }); + }, + async updateTransactions() { + isLoggedIn(authData); + const accessToken = await db.query.plaidAccessTokens.findFirst({ + where: eq(plaidAccessTokens.userId, authData.user.id) + }); + if (!accessToken) throw Error("No plaid account"); + + const { data } = await plaidClient.transactionsGet({ + access_token: accessToken.token, + start_date: "2025-10-01", + end_date: "2025-10-15", + }); + + for (const t of data.transactions) { + await db.insert(transaction).values({ + id: randomUUID(), + user_id: authData.user.id, + name: t.name, + amount: t.amount, + }); + } + } } } as const satisfies Mutators; diff --git a/app/_layout.tsx b/app/_layout.tsx index 2688baf..b111263 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -45,6 +45,7 @@ export default function RootLayout() { + diff --git a/app/index.tsx b/app/index.tsx index 6158cbb..6c3aaab 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,40 +1,47 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { authClient } from '@/lib/auth-client'; -import { Button, Text } from 'react-native'; +import { Button, Linking, ScrollView, Text, View } from 'react-native'; import { useQuery, useZero } from "@rocicorp/zero/react"; import { queries, type Mutators, type Schema } from '@money/shared'; -import { randomUUID } from "expo-crypto"; +import { useEffect, useState } from 'react'; export default function HomeScreen() { const { data: session } = authClient.useSession(); - const onLogout = () => { - authClient.signOut(); - } const z = useZero(); + const [plaidLink] = useQuery(queries.getPlaidLink(session)); const [transactions] = useQuery(queries.allTransactions(session)); - const [user] = useQuery(queries.me(session)); - const onNew = () => { - z.mutate.transaction.create({ - id: randomUUID(), - name: "Uber", - amount: 100, - }) - }; + const [idx, setIdx] = useState(0); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "j") { + setIdx((prevIdx) => { + if (prevIdx + 1 == transactions.length) return prevIdx; + return prevIdx + 1 + }); + } else if (event.key === "k") { + setIdx((prevIdx) => prevIdx == 0 ? 0 : prevIdx - 1); + } + }; + + window.addEventListener("keydown", handleKeyDown); + + // Cleanup listener on unmount + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [transactions]); return ( - - Hello {user?.name} -