Skip to content

Commit

Permalink
Reconnect (#359)
Browse files Browse the repository at this point in the history
* wip

* Refresh page after connect

* wip
  • Loading branch information
pontusab authored Feb 27, 2025
1 parent 41b3732 commit 0ee8474
Show file tree
Hide file tree
Showing 11 changed files with 226 additions and 24 deletions.
2 changes: 1 addition & 1 deletion apps/dashboard/jobs/tasks/bank/sync/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export const syncAccount = schemaTask({
// This is to avoid memory issues with the DB
for (let i = 0; i < transactionsData.length; i += BATCH_SIZE) {
const transactionBatch = transactionsData.slice(i, i + BATCH_SIZE);
await upsertTransactions.trigger({
await upsertTransactions.triggerAndWait({
transactions: transactionBatch,
teamId,
bankAccountId: id,
Expand Down
28 changes: 27 additions & 1 deletion apps/dashboard/jobs/tasks/bank/transactions/upsert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createClient } from "@midday/supabase/job";
import { logger, schemaTask } from "@trigger.dev/sdk/v3";
import { transformTransaction } from "jobs/utils/transform";
import { z } from "zod";
// import { enrichTransactions } from "../../transactions/enrich";

const transactionSchema = z.object({
id: z.string(),
Expand Down Expand Up @@ -43,13 +44,38 @@ export const upsertTransactions = schemaTask({
});

// Upsert transactions into the transactions table, skipping duplicates based on internal_id
await supabase
const { data: upsertedTransactions } = await supabase
.from("transactions")
.upsert(formattedTransactions, {
onConflict: "internal_id",
ignoreDuplicates: true,
})
.select()
.throwOnError();

// Filter out transactions that are not uncategorized expenses
// const uncategorizedExpenses = upsertedTransactions?.filter(
// (transaction) => !transaction.category && transaction.amount < 0,
// );

// if (uncategorizedExpenses?.length) {
// // We only want to wait for enrichment if this is a manual sync
// if (manualSync) {
// await enrichTransactions.triggerAndWait({
// transactions: uncategorizedExpenses.map((transaction) => ({
// id: transaction.id,
// name: transaction.name,
// })),
// });
// } else {
// await enrichTransactions.trigger({
// transactions: uncategorizedExpenses.map((transaction) => ({
// id: transaction.id,
// name: transaction.name,
// })),
// });
// }
// }
} catch (error) {
logger.error("Failed to upsert transactions", { error });

Expand Down
29 changes: 29 additions & 0 deletions apps/dashboard/jobs/tasks/transactions/enrich.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { createClient } from "@midday/supabase/job";
import { schemaTask } from "@trigger.dev/sdk/v3";
import { z } from "zod";
import { EnrichmentService } from "../../utils/enrichment-service";

export const enrichTransactions = schemaTask({
id: "enrich-transactions",
schema: z.object({
transactions: z.array(
z.object({
id: z.string(),
name: z.string(),
}),
),
}),
maxDuration: 300,
queue: {
concurrencyLimit: 10,
},
run: async ({ transactions }) => {
const supabase = createClient();

const enrichmentService = new EnrichmentService();

const data = await enrichmentService.batchEnrichTransactions(transactions);

// const result = await supabase.from("transactions").upsert(data);
},
});
105 changes: 105 additions & 0 deletions apps/dashboard/jobs/utils/enrichment-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { type OpenAIProvider, createOpenAI } from "@ai-sdk/openai";
import { generateObject } from "ai";
import { z } from "zod";

type Transaction = {
id: string;
name: string;
};

type EnrichedTransaction = {
id: string;
category_slug: string | null;
};

export class EnrichmentService {
model: OpenAIProvider;

constructor() {
this.model = createOpenAI({
baseURL: process.env.AI_GATEWAY_ENDPOINT,
apiKey: process.env.AI_GATEWAY_API_KEY,
});
}

async enrichTransactions(
transactions: Transaction[],
): Promise<EnrichedTransaction[]> {
const { object } = await generateObject({
model: this.model.chat(process.env.AI_GATEWAY_MODEL!),
prompt: `You are an expert in categorizing financial transactions for business expense tracking.
Analyze the transaction details and determine the most appropriate category.
Here are the categories and their descriptions:
- travel: Business travel expenses including flights, hotels, car rentals, and other transportation costs
- office_supplies: Office materials like paper, pens, printer supplies, and basic office equipment
- meals: Business meals, client dinners, team lunches, and catering expenses
- software: Software licenses, subscriptions, cloud services, and digital tools
- rent: Office space rental, coworking memberships, and real estate related costs
- equipment: Major hardware purchases, computers, machinery, and durable business equipment
- internet_and_telephone: Internet service, phone plans, mobile devices, and communication expenses
- facilities_expenses: Utilities, maintenance, cleaning, and other building operation costs
- activity: Team building events, conferences, training, and professional development
- taxes: Business tax payments, property taxes, and other tax-related expenses
- fees: Bank fees, service charges, professional fees, and administrative costs
Transactions: ${JSON.stringify(transactions)}
Important: Return the transactions array in the exact same order as provided.`,
temperature: 1,
mode: "json",
schema: z.object({
transactions: z.array(
z.object({
category: z
.enum([
"travel",
"office_supplies",
"meals",
"software",
"rent",
"equipment",
"internet_and_telephone",
"facilities_expenses",
"activity",
"taxes",
"fees",
])
.describe("The most appropriate category for the transaction"),
}),
),
}),
});

return transactions.map((transaction, idx) => ({
id: transaction.id,
category_slug: object.transactions[idx]?.category ?? null,
name: transaction.name,
}));
}

async batchEnrichTransactions(
transactions: Transaction[],
): Promise<EnrichedTransaction[]> {
const MAX_TOKENS_PER_BATCH = 4000;
const ESTIMATED_TOKENS_PER_TRANSACTION = 40;

const batchSize = Math.max(
1,
Math.floor(MAX_TOKENS_PER_BATCH / ESTIMATED_TOKENS_PER_TRANSACTION),
);

const enrichedTransactions: EnrichedTransaction[] = [];

// Process in batches to avoid token limits
for (let i = 0; i < transactions.length; i += batchSize) {
const batch = transactions.slice(i, i + batchSize);
const batchResults = await this.enrichTransactions(batch);

// Add the batch results to our collection
enrichedTransactions.push(...batchResults);
}

return enrichedTransactions;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { client } from "@midday/engine/client";
import { LogEvents } from "@midday/events/events";
import { getCountryCode } from "@midday/location";
import { nanoid } from "nanoid";
import { isDesktopApp } from "@todesktop/client-core/platform/todesktop";
import { redirect } from "next/navigation";
import { authActionClient } from "../safe-action";
import { createEnableBankingLinkSchema } from "../schema";
Expand Down Expand Up @@ -40,7 +40,7 @@ export const createEnableBankingLinkAction = authActionClient
validUntil: new Date(Date.now() + maximumConsentValidity * 1000)
.toISOString()
.replace(/\.\d+Z$/, ".000000+00:00"),
state: nanoid(),
state: isDesktopApp() ? "desktop:connect" : "web:connect",
},
});

Expand Down
71 changes: 52 additions & 19 deletions apps/dashboard/src/app/api/enablebanking/session/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { client } from "@midday/engine/client";
import { getSession } from "@midday/supabase/cached-queries";
import { createClient } from "@midday/supabase/server";
import { type NextRequest, NextResponse } from "next/server";

export const preferredRegion = ["fra1", "sfo1", "iad1"];
Expand All @@ -7,9 +9,25 @@ export const dynamic = "force-dynamic";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const requestUrl = new URL(request.url);
const supabase = createClient();

const {
data: { session },
} = await getSession();

if (!session) {
return NextResponse.redirect(new URL("/", requestUrl.origin));
}

const [type, method] = state?.split(":") ?? [];

const isDesktop = type === "desktop";
const redirectBase = isDesktop ? "midday://" : requestUrl.origin;

if (!code) {
return NextResponse.redirect(new URL("/?error=missing_code", request.url));
return NextResponse.redirect(new URL("/?error=missing_code", redirectBase));
}

const sessionResponse = await client.auth.enablebanking.exchange.$get({
Expand All @@ -18,26 +36,41 @@ export async function GET(request: NextRequest) {
},
});

const {
data: sessionData,
code: errorCode,
...rest
} = await sessionResponse.json();

if (sessionData?.session_id) {
return NextResponse.redirect(
new URL(
`/?ref=${sessionData.session_id}&provider=enablebanking&step=account`,
request.url,
),
);
if (method === "connect") {
const { data: sessionData } = await sessionResponse.json();

if (sessionData?.session_id) {
return NextResponse.redirect(
new URL(
`/?ref=${sessionData.session_id}&provider=enablebanking&step=account`,
redirectBase,
),
);
}
}

if (errorCode === "already_authorized") {
return NextResponse.redirect(
new URL("/?error=already_authorized", request.url),
);
if (method === "reconnect") {
const { data: sessionData } = await sessionResponse.json();

if (sessionData?.session_id) {
const { data } = await supabase
.from("bank_connections")
.update({
expires_at: sessionData.expires_at,
status: "connected",
})
.eq("reference_id", sessionData.session_id)
.select("id")
.single();

return NextResponse.redirect(
new URL(
`/settings/accounts?id=${data?.id}&step=reconnect`,
redirectBase,
),
);
}
}

return NextResponse.redirect(new URL("/", request.url));
return NextResponse.redirect(new URL("/", redirectBase));
}
4 changes: 3 additions & 1 deletion apps/dashboard/src/components/loading-transactions-event.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Button } from "@midday/ui/button";
import { cn } from "@midday/ui/cn";
import { useTheme } from "next-themes";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";

const Lottie = dynamic(() => import("lottie-react"), {
Expand All @@ -26,6 +27,7 @@ export function LoadingTransactionsEvent({
}: Props) {
const [step, setStep] = useState(1);
const { resolvedTheme } = useTheme();
const router = useRouter();

const { status } = useInitialConnectionStatus({
runId,
Expand All @@ -42,7 +44,7 @@ export function LoadingTransactionsEvent({

setTimeout(() => {
setRunId(undefined);
setStep(4);
router.push("/");
}, 1000);
}
}, [status]);
Expand Down
3 changes: 3 additions & 0 deletions apps/dashboard/src/components/reconnect-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ export function ReconnectProvider({
isDesktop: isDesktopApp(),
});
}
case "enablebanking": {
return;
}
case "teller":
return openTeller();
default:
Expand Down
1 change: 1 addition & 0 deletions apps/engine/src/routes/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ const app = new OpenAPIHono<{ Bindings: Bindings }>()
{
data: {
session_id: data.session_id,
expires_at: data.access.valid_until,
},
},
200,
Expand Down
3 changes: 3 additions & 0 deletions apps/engine/src/routes/auth/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ export const EnableBankingSessionSchema = z
session_id: z.string().openapi({
example: "234234234",
}),
expires_at: z.string().openapi({
example: "2024-01-01",
}),
}),
})
.openapi("EnableBankingSessionSchema");

0 comments on commit 0ee8474

Please sign in to comment.