feat: query plaid transactions

This commit is contained in:
Max Koon
2025-10-15 10:53:37 -04:00
parent 23987e4f87
commit 415150d58e
12 changed files with 322 additions and 32 deletions

View File

@@ -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"

View File

@@ -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<Schema>;
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;

View File

@@ -45,6 +45,7 @@ export default function RootLayout() {
<Stack>
<Stack.Protected guard={!isPending && !!session}>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
</Stack.Protected>
<Stack.Protected guard={!isPending && !session}>
<Stack.Screen name="auth" />

View File

@@ -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<Schema, Mutators>();
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 (
<SafeAreaView>
<Text>Hello {user?.name}</Text>
<Button onPress={onLogout} title="Logout" />
<Text>Transactions: {JSON.stringify(transactions, null, 4)}</Text>
<Button onPress={onNew} title="New" />
<Button onPress={() => {
z.mutate.transaction.deleteAll();
}} title="Delete" />
<Button onPress={() => {
z.mutate.link.create();
}} title="Open link" />
</SafeAreaView>
<ScrollView>
{plaidLink && <Button onPress={() => {
z.mutate.link.updateTransactions();
}} title="Update transactions" />}
{transactions.map((t, i) => <View style={{ backgroundColor: i == idx ? 'black' : undefined }} key={t.id}>
<Text style={{ fontFamily: 'mono', color: i == idx ? 'white' : undefined }}>{t.name} {t.amount}</Text>
</View>)}
</ScrollView>
);
}

41
app/settings.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { SafeAreaView } from 'react-native-safe-area-context';
import { authClient } from '@/lib/auth-client';
import { Button, Linking, Text } from 'react-native';
import { useQuery, useZero } from "@rocicorp/zero/react";
import { queries, type Mutators, type Schema } from '@money/shared';
export default function HomeScreen() {
const { data: session } = authClient.useSession();
const onLogout = () => {
authClient.signOut();
}
const z = useZero<Schema, Mutators>();
const [user] = useQuery(queries.me(session));
const [plaidLink] = useQuery(queries.getPlaidLink(session));
return (
<SafeAreaView>
<Text>Hello {user?.name}</Text>
<Button onPress={onLogout} title="Logout" />
<Text>{JSON.stringify(plaidLink)}</Text>
{plaidLink ? <Button onPress={() => {
Linking.openURL(plaidLink.link);
}} title="Open Plaid" /> : <Text>No plaid link</Text>}
<Button onPress={() => {
z.mutate.link.create();
}} title="Generate Link" />
{plaidLink && <Button onPress={() => {
z.mutate.link.get({ link_token: plaidLink.token });
}} title="Check Link" />}
{plaidLink && <Button onPress={() => {
z.mutate.link.updateTransactions();
}} title="Update transactions" />}
</SafeAreaView>
);
}

71
pnpm-lock.yaml generated
View File

@@ -144,6 +144,9 @@ importers:
hono:
specifier: ^4.9.12
version: 4.9.12
plaid:
specifier: ^39.0.0
version: 39.0.0
devDependencies:
'@types/node':
specifier: ^24.7.2
@@ -2738,6 +2741,9 @@ packages:
async-limiter@1.0.1:
resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
@@ -2752,6 +2758,9 @@ packages:
await-lock@2.2.2:
resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==}
axios@1.12.2:
resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==}
babel-jest@29.7.0:
resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -3056,6 +3065,10 @@ packages:
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
engines: {node: '>=12.5.0'}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
command-line-args@6.0.1:
resolution: {integrity: sha512-Jr3eByUjqyK0qd8W0SGFW1nZwqCaNCtbXjRo2cRJC1OYxWl3MZ5t1US3jq+cO4sPavqgw4l9BMGX0CBe+trepg==}
engines: {node: '>=12.20'}
@@ -3215,6 +3228,10 @@ packages:
defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@@ -3903,6 +3920,15 @@ packages:
flow-enums-runtime@0.0.6:
resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
fontfaceobserver@2.3.0:
resolution: {integrity: sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==}
@@ -3914,6 +3940,10 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
form-data@4.0.4:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
forwarded-parse@2.1.2:
resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==}
@@ -5112,6 +5142,10 @@ packages:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
plaid@39.0.0:
resolution: {integrity: sha512-7WuI97/R/xzjQV8ZSWY/rx5mFjjLDbIuGpFrOq0AolqplwT9StHe0ADa+H5rrpeOi6NNr+DdN7Ace/TACYME1w==}
engines: {node: '>=10.0.0'}
plist@3.1.0:
resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
engines: {node: '>=10.4.0'}
@@ -5212,6 +5246,9 @@ packages:
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
engines: {node: '>=12.0.0'}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
pump@3.0.3:
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
@@ -9459,6 +9496,8 @@ snapshots:
async-limiter@1.0.1: {}
asynckit@0.4.0: {}
atomic-sleep@1.0.0: {}
available-typed-arrays@1.0.7:
@@ -9472,6 +9511,14 @@ snapshots:
await-lock@2.2.2: {}
axios@1.12.2:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.4
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
babel-jest@29.7.0(@babel/core@7.28.4):
dependencies:
'@babel/core': 7.28.4
@@ -9841,6 +9888,10 @@ snapshots:
color-convert: 2.0.1
color-string: 1.9.1
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
command-line-args@6.0.1:
dependencies:
array-back: 6.2.2
@@ -9993,6 +10044,8 @@ snapshots:
defu@6.1.4: {}
delayed-stream@1.0.0: {}
depd@2.0.0: {}
dequal@2.0.3: {}
@@ -10820,6 +10873,8 @@ snapshots:
flow-enums-runtime@0.0.6: {}
follow-redirects@1.15.11: {}
fontfaceobserver@2.3.0: {}
for-each@0.3.5:
@@ -10831,6 +10886,14 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
form-data@4.0.4:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
forwarded-parse@2.1.2: {}
freeport-async@2.0.0: {}
@@ -12225,6 +12288,12 @@ snapshots:
pirates@4.0.7: {}
plaid@39.0.0:
dependencies:
axios: 1.12.2
transitivePeerDependencies:
- debug
plist@3.1.0:
dependencies:
'@xmldom/xmldom': 0.8.11
@@ -12328,6 +12397,8 @@ snapshots:
'@types/node': 24.7.2
long: 5.3.2
proxy-from-env@1.1.0: {}
pump@3.0.3:
dependencies:
end-of-stream: 1.4.5

View File

@@ -92,3 +92,10 @@ export const auditLogs = pgTable("audit_log", {
.references(() => users.id, { onDelete: "cascade" }),
action: text("action").notNull(),
});
export const plaidAccessTokens = pgTable("plaidAccessToken", {
id: text("id").primaryKey(),
userId: text("user_id").notNull(),
token: text("token").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});

View File

@@ -1,4 +1,4 @@
import { integer, pgTable, text, boolean, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
import { pgTable, text, boolean, timestamp, uniqueIndex, decimal } from "drizzle-orm/pg-core";
export const users = pgTable(
"user",
@@ -18,7 +18,14 @@ export const transaction = pgTable("transaction", {
id: text("id").primaryKey(),
user_id: text("user_id").notNull(),
name: text("name").notNull(),
amount: integer("amount").notNull(),
amount: decimal("amount").notNull(),
});
export const plaidLink = pgTable("plaidLink", {
id: text("id").primaryKey(),
user_id: text("user_id").notNull(),
link: text("link").notNull(),
token: text("token").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});

View File

@@ -25,7 +25,9 @@ export function createMutators(authData: AuthData | null) {
},
},
link: {
async create() {}
async create() {},
async get(tx: Tx, { link_token }: { link_token: string }) {},
async updateTransactions() {},
}
} as const;
}

View File

@@ -17,5 +17,12 @@ export const queries = {
return builder.users
.where('id', '=', authData.user.id)
.one();
})
}),
getPlaidLink: syncedQueryWithContext('getPlaidLink', z.tuple([]), (authData: AuthData | null) => {
isLoggedIn(authData);
return builder.plaidLink
.where('user_id', '=', authData.user.id)
.orderBy('createdAt', 'desc')
.one();
}),
};

View File

@@ -1,4 +1,6 @@
import { definePermissions } from "@rocicorp/zero";
import { schema } from "./zero-schema.gen";
import { schema as schemaGen } from "./zero-schema.gen";
export const schema = schemaGen;
export const permissions = definePermissions(schema, () => ({}));

View File

@@ -21,6 +21,58 @@ type ZeroSchema = DrizzleToZeroSchema<typeof drizzleSchema>;
*/
export const schema = {
tables: {
plaidLink: {
name: "plaidLink",
columns: {
id: {
type: "string",
optional: false,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"plaidLink",
"id"
>,
},
user_id: {
type: "string",
optional: false,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"plaidLink",
"user_id"
>,
},
link: {
type: "string",
optional: false,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"plaidLink",
"link"
>,
},
token: {
type: "string",
optional: false,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"plaidLink",
"token"
>,
},
createdAt: {
type: "number",
optional: true,
customType: null as unknown as ZeroCustomType<
ZeroSchema,
"plaidLink",
"createdAt"
>,
serverName: "created_at",
},
},
primaryKey: ["id"],
},
transaction: {
name: "transaction",
columns: {
@@ -147,6 +199,11 @@ export const schema = {
* This type is auto-generated from your Drizzle schema definition.
*/
export type Schema = typeof schema;
/**
* Represents a row from the "plaidLink" table.
* This type is auto-generated from your Drizzle schema definition.
*/
export type PlaidLink = Row<Schema["tables"]["plaidLink"]>;
/**
* Represents a row from the "transaction" table.
* This type is auto-generated from your Drizzle schema definition.