From 7fb047d5a8d8d3ddbfc7dfed5ab524673f895b07 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 3 Jul 2025 09:46:24 +0000
Subject: [PATCH 1/5] Add Trigger.dev tasks for Plaid and GoCardless financial
integrations
Co-authored-by: christer.hagen
---
TRIGGER_INTEGRATION.md | 269 +++++++++++
src/trigger/example.ts | 16 -
src/trigger/gocardless-tasks.ts | 819 ++++++++++++++++++++++++++++++++
src/trigger/index.ts | 21 +
src/trigger/plaid-tasks.ts | 487 +++++++++++++++++++
5 files changed, 1596 insertions(+), 16 deletions(-)
create mode 100644 TRIGGER_INTEGRATION.md
delete mode 100644 src/trigger/example.ts
create mode 100644 src/trigger/gocardless-tasks.ts
create mode 100644 src/trigger/index.ts
create mode 100644 src/trigger/plaid-tasks.ts
diff --git a/TRIGGER_INTEGRATION.md b/TRIGGER_INTEGRATION.md
new file mode 100644
index 00000000..ef36538c
--- /dev/null
+++ b/TRIGGER_INTEGRATION.md
@@ -0,0 +1,269 @@
+# Trigger.dev Integration for Plaid and GoCardless
+
+## Overview
+
+Yes, **you can absolutely use trigger.dev functions for both Plaid and GoCardless operations!** This integration provides several key benefits:
+
+### Benefits
+
+1. **Async Processing**: Long-running operations like transaction imports won't block your UI
+2. **Reliability**: Built-in retries and error handling for external API calls
+3. **Scheduling**: Easy setup for periodic data syncing (daily, weekly, etc.)
+4. **Monitoring**: Better observability for financial data operations
+5. **Rate Limiting**: Handle API rate limits more gracefully
+6. **Scalability**: Background processing scales independently
+
+## Implementation Status
+
+I've created trigger.dev task implementations for both Plaid and GoCardless:
+
+- ✅ `src/trigger/plaid-tasks.ts` - Plaid operations
+- ✅ `src/trigger/gocardless-tasks.ts` - GoCardless operations
+
+## Available Tasks
+
+### Plaid Tasks (`src/trigger/plaid-tasks.ts`)
+
+1. **`exchangePlaidPublicToken`** - Exchange public token for access token and create accounts
+2. **`importPlaidTransactions`** - Import transactions from all connected Plaid accounts
+3. **`syncPlaidBalances`** - Sync account balances from Plaid
+4. **`scheduledPlaidBalanceSync`** - Daily scheduled balance sync (6 AM)
+5. **`scheduledPlaidTransactionImport`** - Daily scheduled transaction import (7 AM)
+
+### GoCardless Tasks (`src/trigger/gocardless-tasks.ts`)
+
+1. **`completeGoCardlessConnection`** - Complete connection after user authorization
+2. **`importGoCardlessTransactions`** - Import transactions from GoCardless accounts
+3. **`syncGoCardlessBalances`** - Sync account balances from GoCardless
+4. **`scheduledGoCardlessBalanceSync`** - Daily scheduled balance sync (6 AM)
+5. **`scheduledGoCardlessTransactionImport`** - Daily scheduled transaction import (7 AM)
+
+## Usage Examples
+
+### Triggering Tasks from Your Actions
+
+Instead of calling the synchronous functions directly, trigger the async tasks:
+
+```typescript
+// Before (synchronous)
+import { exchangePublicToken } from "@/actions/plaid-actions";
+const result = await exchangePublicToken(publicToken);
+
+// After (asynchronous with trigger.dev)
+import { exchangePlaidPublicToken } from "@/trigger/plaid-tasks";
+
+const taskRun = await exchangePlaidPublicToken.trigger({
+ publicToken,
+ familyId: user.familyId
+});
+
+// Optional: Wait for completion if needed
+const result = await taskRun.waitForCompletion();
+```
+
+### Manual Transaction Import
+
+```typescript
+import { importPlaidTransactions } from "@/trigger/plaid-tasks";
+import { importGoCardlessTransactions } from "@/trigger/gocardless-tasks";
+
+// Import last 30 days of transactions
+const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
+const endDate = new Date();
+
+// Trigger both Plaid and GoCardless imports in parallel
+await Promise.all([
+ importPlaidTransactions.trigger({
+ familyId: user.familyId,
+ startDate: startDate.toISOString(),
+ endDate: endDate.toISOString()
+ }),
+ importGoCardlessTransactions.trigger({
+ familyId: user.familyId,
+ startDate: startDate.toISOString(),
+ endDate: endDate.toISOString()
+ })
+]);
+```
+
+### One-off Balance Sync
+
+```typescript
+import { syncPlaidBalances } from "@/trigger/plaid-tasks";
+import { syncGoCardlessBalances } from "@/trigger/gocardless-tasks";
+
+// Trigger balance sync for all provider types
+await Promise.all([
+ syncPlaidBalances.trigger({ familyId: user.familyId }),
+ syncGoCardlessBalances.trigger({ familyId: user.familyId })
+]);
+```
+
+## Update Frequency Configuration
+
+The scheduled tasks are configured with cron expressions that you can easily modify:
+
+### Current Schedule
+- **Balance Sync**: `"0 6 * * *"` (Daily at 6 AM)
+- **Transaction Import**: `"0 7 * * *"` (Daily at 7 AM)
+
+### Custom Schedules
+
+You can modify the cron expressions for different update frequencies:
+
+```typescript
+// Every 4 hours
+cron: "0 */4 * * *"
+
+// Twice daily (6 AM and 6 PM)
+cron: "0 6,18 * * *"
+
+// Every weekday at 8 AM
+cron: "0 8 * * 1-5"
+
+// Weekly on Sundays at 2 AM
+cron: "0 2 * * 0"
+```
+
+## Integration Steps
+
+### 1. Fix Import Issues
+
+The trigger files have some import path issues that need to be resolved:
+
+```bash
+# Generate Prisma client if not already done
+npm run db:generate
+
+# Check if @/generated/prisma path exists, if not, update imports to use:
+# import { PrismaClient, AccountType } from "@prisma/client";
+```
+
+### 2. Update Your Existing Actions
+
+Replace direct function calls with trigger.dev task triggers:
+
+**Before:**
+```typescript
+// src/actions/plaid-actions.ts
+export async function exchangePublicToken(publicToken: string) {
+ // Long-running synchronous operation
+}
+```
+
+**After:**
+```typescript
+// src/actions/plaid-actions.ts
+import { exchangePlaidPublicToken } from "@/trigger/plaid-tasks";
+
+export async function exchangePublicToken(publicToken: string) {
+ const familyId = await getActiveFamilyId();
+ if (!familyId) throw new Error("No family found");
+
+ // Trigger async task
+ const taskRun = await exchangePlaidPublicToken.trigger({
+ publicToken,
+ familyId
+ });
+
+ return {
+ success: true,
+ taskId: taskRun.id,
+ message: "Account connection started. You'll be notified when complete."
+ };
+}
+```
+
+### 3. Update UI Components
+
+Handle the async nature in your UI:
+
+```typescript
+// Show loading state immediately
+const [isConnecting, setIsConnecting] = useState(false);
+
+const handleConnect = async (publicToken: string) => {
+ setIsConnecting(true);
+
+ try {
+ const result = await exchangePublicToken(publicToken);
+ toast.success("Connection started! Processing in background...");
+
+ // Optionally poll for completion or use webhooks
+
+ } catch (error) {
+ toast.error("Failed to start connection");
+ } finally {
+ setIsConnecting(false);
+ }
+};
+```
+
+### 4. Environment Variables
+
+Ensure all required environment variables are set:
+
+```env
+# Plaid
+PLAID_CLIENT_ID=your_plaid_client_id
+PLAID_SECRET=your_plaid_secret
+
+# GoCardless
+GOCARDLESS_SECRET_ID=your_gocardless_secret_id
+GOCARDLESS_SECRET_KEY=your_gocardless_secret_key
+
+# Trigger.dev
+TRIGGER_SECRET_KEY=your_trigger_secret_key
+```
+
+### 5. Deploy Trigger Functions
+
+Deploy your trigger functions:
+
+```bash
+# Deploy to trigger.dev
+npx trigger.dev@latest deploy
+```
+
+## Monitoring and Observability
+
+With trigger.dev, you get:
+
+1. **Dashboard**: View all task runs, success/failure rates
+2. **Logs**: Detailed logging for each operation
+3. **Retries**: Automatic retries with exponential backoff
+4. **Alerts**: Get notified of failed operations
+5. **Metrics**: Track performance and success rates
+
+## Error Handling
+
+The trigger functions include comprehensive error handling:
+
+- **Retry Logic**: Failed operations are automatically retried
+- **Detailed Logging**: All operations are logged with context
+- **Graceful Degradation**: Individual account failures don't stop the entire process
+- **User Notifications**: Users can be notified of completion/failures
+
+## Testing
+
+Test your trigger functions locally:
+
+```bash
+# Start trigger.dev dev server
+npx trigger.dev@latest dev
+
+# Trigger a test run
+curl -X POST http://localhost:3040/api/trigger \
+ -H "Content-Type: application/json" \
+ -d '{"taskId": "import-plaid-transactions", "payload": {"familyId": "test-family"}}'
+```
+
+## Next Steps
+
+1. **Fix Import Paths**: Resolve the Prisma and trigger.dev import issues
+2. **Update Actions**: Replace synchronous calls with trigger.dev tasks
+3. **Update UI**: Handle async operations in your frontend
+4. **Deploy**: Deploy trigger functions to production
+5. **Monitor**: Set up alerts and monitoring
+
+This architecture will make your financial data operations much more robust, scalable, and user-friendly!
\ No newline at end of file
diff --git a/src/trigger/example.ts b/src/trigger/example.ts
deleted file mode 100644
index 9ef3d529..00000000
--- a/src/trigger/example.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { logger, task, wait } from "@trigger.dev/sdk/v3";
-
-export const helloWorldTask = task({
- id: "hello-world",
- // Set an optional maxDuration to prevent tasks from running indefinitely
- maxDuration: 300, // Stop executing after 300 secs (5 mins) of compute
- run: async (payload: any, { ctx }) => {
- logger.log("Hello, world!", { payload, ctx });
-
- await wait.for({ seconds: 5 });
-
- return {
- message: "Hello, world!",
- }
- },
-});
\ No newline at end of file
diff --git a/src/trigger/gocardless-tasks.ts b/src/trigger/gocardless-tasks.ts
new file mode 100644
index 00000000..04c2376e
--- /dev/null
+++ b/src/trigger/gocardless-tasks.ts
@@ -0,0 +1,819 @@
+import { logger, task, schedules } from "@trigger.dev/sdk/v3";
+import { PrismaClient, AccountType, Prisma } from "@/generated/prisma";
+import type { BankInfo } from "@/data/banks";
+
+// GoCardless Bank Account Data API base URL
+const GOCARDLESS_API_BASE = "https://bankaccountdata.gocardless.com/api/v2";
+
+// Types for GoCardless API responses
+interface TokenResponse {
+ access: string;
+ access_expires: number;
+ refresh: string;
+ refresh_expires: number;
+}
+
+interface AccountDetails {
+ resourceId: string;
+ iban?: string;
+ bban?: string;
+ msisdn?: string;
+ currency: string;
+ name?: string;
+ displayName?: string;
+ product?: string;
+ cashAccountType?: string;
+ status?: string;
+ bic?: string;
+ linkedAccounts?: string;
+ usage?: string;
+ details?: string;
+ ownerName?: string;
+ ownerAddressStructured?: Record;
+ ownerAddressUnstructured?: string;
+}
+
+interface Balance {
+ balanceAmount: {
+ amount: string;
+ currency: string;
+ };
+ balanceType: string;
+ referenceDate?: string;
+ lastChangeDateTime?: string;
+}
+
+interface Transaction {
+ transactionId: string;
+ debtorName?: string;
+ debtorAccount?: {
+ iban?: string;
+ bban?: string;
+ };
+ creditorName?: string;
+ creditorAccount?: {
+ iban?: string;
+ bban?: string;
+ };
+ transactionAmount: {
+ amount: string;
+ currency: string;
+ };
+ bookingDate?: string;
+ valueDate?: string;
+ remittanceInformationUnstructured?: string;
+ remittanceInformationStructured?: string;
+ additionalInformation?: string;
+ bankTransactionCode?: string;
+ proprietaryBankTransactionCode?: string;
+ endToEndId?: string;
+ mandateId?: string;
+ checkId?: string;
+ creditorId?: string;
+ ultimateCreditor?: string;
+ ultimateDebtor?: string;
+ purposeCode?: string;
+ exchangeRate?: Record[];
+ currencyExchange?: Record[];
+}
+
+interface ConnectedAccountWithFinancialAccount {
+ id: string;
+ providerAccountId: string;
+ accountName: string;
+ accountType: string;
+ accountSubtype: string | null;
+ iban: string | null;
+ connectionId: string;
+ financialAccountId: string | null;
+ financialAccount: {
+ id: string;
+ createdAt: Date;
+ updatedAt: Date;
+ name: string;
+ familyId: string;
+ description: string | null;
+ currency: string;
+ type: AccountType;
+ balance: Prisma.Decimal;
+ isActive: boolean;
+ institution: string | null;
+ accountNumber: string | null;
+ color: string | null;
+ } | null;
+}
+
+function getPrismaClient() {
+ return new PrismaClient();
+}
+
+/**
+ * Generate access token for GoCardless API
+ */
+async function generateAccessToken(): Promise {
+ try {
+ const response = await fetch(`${GOCARDLESS_API_BASE}/token/new/`, {
+ method: "POST",
+ headers: {
+ accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ secret_id: process.env.GOCARDLESS_SECRET_ID!,
+ secret_key: process.env.GOCARDLESS_SECRET_KEY!,
+ }),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`Token generation failed: ${response.status} ${errorText}`);
+ }
+
+ const tokenData: TokenResponse = await response.json();
+ return tokenData.access;
+ } catch (error) {
+ logger.error("Error generating GoCardless access token", { error });
+ throw new Error(`Failed to generate access token: ${error instanceof Error ? error.message : "Unknown error"}`);
+ }
+}
+
+/**
+ * Get account details from GoCardless API
+ */
+async function getAccountDetails(
+ accountId: string,
+ accessToken: string
+): Promise {
+ const response = await fetch(
+ `${GOCARDLESS_API_BASE}/accounts/${accountId}/details/`,
+ {
+ method: "GET",
+ headers: {
+ accept: "application/json",
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }
+ );
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`Failed to fetch account details: ${response.status} ${errorText}`);
+ }
+
+ const data = await response.json();
+ return data.account || data;
+}
+
+/**
+ * Get account balances from GoCardless API
+ */
+async function getAccountBalances(
+ accountId: string,
+ accessToken: string
+): Promise<{ balances: Balance[] }> {
+ const response = await fetch(
+ `${GOCARDLESS_API_BASE}/accounts/${accountId}/balances/`,
+ {
+ method: "GET",
+ headers: {
+ accept: "application/json",
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }
+ );
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`Failed to fetch account balances: ${response.status} ${errorText}`);
+ }
+
+ return await response.json();
+}
+
+/**
+ * Get account transactions from GoCardless API
+ */
+async function getAccountTransactions(
+ accountId: string,
+ accessToken: string,
+ dateFrom?: string,
+ dateTo?: string
+): Promise<{
+ transactions: { booked: Transaction[]; pending: Transaction[] };
+}> {
+ let url = `${GOCARDLESS_API_BASE}/accounts/${accountId}/transactions/`;
+ const params = new URLSearchParams();
+
+ if (dateFrom) params.append("date_from", dateFrom);
+ if (dateTo) params.append("date_to", dateTo);
+
+ if (params.toString()) {
+ url += `?${params.toString()}`;
+ }
+
+ const response = await fetch(url, {
+ method: "GET",
+ headers: {
+ accept: "application/json",
+ Authorization: `Bearer ${accessToken}`,
+ },
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`Failed to fetch account transactions: ${response.status} ${errorText}`);
+ }
+
+ return await response.json();
+}
+
+// Map GoCardless account type to our AccountType enum
+const getAccountType = (details: AccountDetails): AccountType => {
+ if (details.cashAccountType) {
+ switch (details.cashAccountType.toLowerCase()) {
+ case "cacc": // Current account
+ case "tran": // Transactional account
+ return AccountType.CHECKING;
+ case "sava": // Savings account
+ return AccountType.SAVINGS;
+ case "loan": // Loan account
+ return AccountType.LOAN;
+ case "card": // Card account
+ return AccountType.CREDIT_CARD;
+ default:
+ return AccountType.OTHER;
+ }
+ }
+
+ // Fallback based on account type or name
+ if (details.product?.toLowerCase().includes("savings")) {
+ return AccountType.SAVINGS;
+ }
+ if (details.product?.toLowerCase().includes("credit")) {
+ return AccountType.CREDIT_CARD;
+ }
+ if (details.product?.toLowerCase().includes("loan")) {
+ return AccountType.LOAN;
+ }
+
+ return AccountType.CHECKING; // Default to checking
+};
+
+/**
+ * Complete GoCardless connection after user authorization
+ */
+export const completeGoCardlessConnection = task({
+ id: "complete-gocardless-connection",
+ maxDuration: 300, // 5 minutes
+ run: async (payload: {
+ requisitionId: string;
+ bank: BankInfo;
+ familyId: string
+ }) => {
+ const { requisitionId, bank, familyId } = payload;
+ const prisma = getPrismaClient();
+
+ logger.log("Starting GoCardless connection completion", {
+ requisitionId,
+ bankId: bank.id,
+ familyId
+ });
+
+ try {
+ // Generate access token
+ const accessToken = await generateAccessToken();
+
+ // Get requisition details to fetch account IDs
+ const response = await fetch(
+ `${GOCARDLESS_API_BASE}/requisitions/${requisitionId}/`,
+ {
+ method: "GET",
+ headers: {
+ accept: "application/json",
+ Authorization: `Bearer ${accessToken}`,
+ },
+ }
+ );
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`Failed to fetch requisition: ${response.status} ${errorText}`);
+ }
+
+ const requisition = await response.json();
+ const accountIds: string[] = requisition.accounts;
+
+ if (!accountIds.length) {
+ throw new Error("No accounts found in requisition");
+ }
+
+ logger.log("Found accounts in requisition", {
+ accountCount: accountIds.length,
+ accountIds
+ });
+
+ // Create bank connection record
+ const bankConnection = await prisma.connectedAccount.create({
+ data: {
+ provider: "GOCARDLESS",
+ accessToken: requisitionId, // For GoCardless, we use requisition ID as access token
+ familyId,
+ institutionId: bank.institutionId?.gocardless || null,
+ },
+ });
+
+ const createdAccounts = [];
+ let totalTransactions = 0;
+
+ // Process each account
+ for (const accountId of accountIds) {
+ logger.log("Processing GoCardless account", { accountId });
+
+ // Get account details
+ const details = await getAccountDetails(accountId, accessToken);
+
+ // Get account balances
+ const balanceData = await getAccountBalances(accountId, accessToken);
+ const currentBalance = balanceData.balances.find(b =>
+ b.balanceType === "closingBooked" || b.balanceType === "expected"
+ );
+
+ // Create financial account
+ const financialAccount = await prisma.financialAccount.create({
+ data: {
+ name: details.name || details.displayName || `Account ${details.resourceId}`,
+ type: getAccountType(details),
+ balance: currentBalance ? parseFloat(currentBalance.balanceAmount.amount) : 0,
+ currency: details.currency || "EUR",
+ institution: bank.displayName,
+ accountNumber: details.iban || details.bban || `****${details.resourceId.slice(-4)}`,
+ familyId,
+ },
+ });
+
+ // Create connected account mapping
+ const connectedAccountRecord = await prisma.connectedAccount.create({
+ data: {
+ providerAccountId: accountId,
+ accountName: details.name || details.displayName || `Account ${details.resourceId}`,
+ accountType: details.cashAccountType || "unknown",
+ accountSubtype: details.product || null,
+ iban: details.iban || null,
+ connectionId: bankConnection.id,
+ financialAccountId: financialAccount.id,
+ },
+ });
+
+ createdAccounts.push({
+ ...financialAccount,
+ gocardlessAccountId: accountId,
+ });
+
+ // Import transactions for this account (last 90 days)
+ const endDate = new Date();
+ const startDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
+
+ try {
+ const transactionData = await getAccountTransactions(
+ accountId,
+ accessToken,
+ startDate.toISOString().split("T")[0],
+ endDate.toISOString().split("T")[0]
+ );
+
+ const allTransactions = [
+ ...transactionData.transactions.booked,
+ ...transactionData.transactions.pending.map(t => ({ ...t, pending: true }))
+ ];
+
+ logger.log("Retrieved transactions from GoCardless", {
+ accountId,
+ transactionCount: allTransactions.length
+ });
+
+ // Process transactions
+ for (const transaction of allTransactions) {
+ await processGoCardlessTransaction(
+ transaction,
+ { ...connectedAccountRecord, financialAccount } as ConnectedAccountWithFinancialAccount,
+ familyId,
+ !!(transaction as any).pending,
+ prisma
+ );
+ totalTransactions++;
+ }
+ } catch (transactionError) {
+ logger.warn("Failed to import transactions for account", {
+ accountId,
+ error: transactionError
+ });
+ // Continue with other accounts even if transaction import fails
+ }
+ }
+
+ logger.log("Completed GoCardless connection", {
+ familyId,
+ accountCount: createdAccounts.length,
+ totalTransactions
+ });
+
+ return {
+ success: true,
+ accounts: createdAccounts,
+ transactionsImported: totalTransactions,
+ message: `Successfully connected ${createdAccounts.length} accounts and imported ${totalTransactions} transactions via GoCardless`,
+ };
+ } catch (error) {
+ logger.error("Error completing GoCardless connection", {
+ error,
+ requisitionId,
+ familyId
+ });
+ throw new Error(`Failed to complete GoCardless connection: ${error instanceof Error ? error.message : "Unknown error"}`);
+ } finally {
+ await prisma.$disconnect();
+ }
+ },
+});
+
+/**
+ * Import transactions from GoCardless accounts
+ */
+export const importGoCardlessTransactions = task({
+ id: "import-gocardless-transactions",
+ maxDuration: 600, // 10 minutes for large transaction imports
+ run: async (payload: {
+ familyId: string;
+ startDate?: string;
+ endDate?: string;
+ }) => {
+ const { familyId, startDate, endDate } = payload;
+ const prisma = getPrismaClient();
+
+ logger.log("Starting GoCardless transaction import", { familyId, startDate, endDate });
+
+ try {
+ // Get all GoCardless connections for this family
+ const connections = await prisma.connectedAccount.findMany({
+ where: {
+ familyId,
+ provider: "GOCARDLESS",
+ },
+ include: {
+ financialAccount: true,
+ },
+ });
+
+ if (connections.length === 0) {
+ throw new Error("No GoCardless accounts connected");
+ }
+
+ logger.log("Found GoCardless connections", { connectionCount: connections.length });
+
+ // Generate access token
+ const accessToken = await generateAccessToken();
+
+ let totalTransactions = 0;
+
+ // Set date range (default to last 7 days)
+ const end = endDate ? new Date(endDate) : new Date();
+ const start = startDate ? new Date(startDate) : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+
+ for (const connection of connections) {
+ if (!connection.financialAccount) continue;
+
+ logger.log("Processing GoCardless connection", {
+ connectionId: connection.id,
+ accountId: connection.providerAccountId
+ });
+
+ try {
+ // Get transactions from GoCardless API
+ const transactionData = await getAccountTransactions(
+ connection.providerAccountId,
+ accessToken,
+ start.toISOString().split("T")[0],
+ end.toISOString().split("T")[0]
+ );
+
+ const allTransactions = [
+ ...transactionData.transactions.booked,
+ ...transactionData.transactions.pending.map((t: any) => ({ ...t, pending: true }))
+ ];
+
+ logger.log("Retrieved transactions from GoCardless", {
+ accountId: connection.providerAccountId,
+ transactionCount: allTransactions.length
+ });
+
+ // Process transactions
+ for (const transaction of allTransactions) {
+ await processGoCardlessTransaction(
+ transaction,
+ connection as ConnectedAccountWithFinancialAccount,
+ familyId,
+ !!(transaction as any).pending,
+ prisma
+ );
+ totalTransactions++;
+ }
+ } catch (accountError) {
+ logger.warn("Failed to import transactions for account", {
+ accountId: connection.providerAccountId,
+ error: accountError
+ });
+ // Continue with other accounts
+ }
+ }
+
+ logger.log("Completed GoCardless transaction import", {
+ familyId,
+ totalTransactions
+ });
+
+ return {
+ success: true,
+ transactionsImported: totalTransactions,
+ message: `Successfully imported ${totalTransactions} transactions from GoCardless`,
+ };
+ } catch (error) {
+ logger.error("Error importing GoCardless transactions", { error, familyId });
+ throw new Error(`Failed to import GoCardless transactions: ${error instanceof Error ? error.message : "Unknown error"}`);
+ } finally {
+ await prisma.$disconnect();
+ }
+ },
+});
+
+/**
+ * Sync GoCardless account balances
+ */
+export const syncGoCardlessBalances = task({
+ id: "sync-gocardless-balances",
+ maxDuration: 300, // 5 minutes
+ run: async (payload: { familyId: string }) => {
+ const { familyId } = payload;
+ const prisma = getPrismaClient();
+
+ logger.log("Starting GoCardless balance sync", { familyId });
+
+ try {
+ // Get all GoCardless connections for this family
+ const connections = await prisma.connectedAccount.findMany({
+ where: {
+ familyId,
+ provider: "GOCARDLESS",
+ },
+ include: {
+ financialAccount: true,
+ },
+ });
+
+ logger.log("Found GoCardless connections for balance sync", {
+ connectionCount: connections.length
+ });
+
+ // Generate access token
+ const accessToken = await generateAccessToken();
+
+ let accountsUpdated = 0;
+
+ for (const connection of connections) {
+ if (!connection.financialAccount) continue;
+
+ try {
+ // Get account balances
+ const balanceData = await getAccountBalances(
+ connection.providerAccountId,
+ accessToken
+ );
+
+ const currentBalance = balanceData.balances.find(b =>
+ b.balanceType === "closingBooked" || b.balanceType === "expected"
+ );
+
+ if (currentBalance) {
+ await prisma.financialAccount.update({
+ where: { id: connection.financialAccount.id },
+ data: {
+ balance: parseFloat(currentBalance.balanceAmount.amount),
+ },
+ });
+ accountsUpdated++;
+ }
+ } catch (accountError) {
+ logger.warn("Failed to sync balance for account", {
+ accountId: connection.providerAccountId,
+ error: accountError
+ });
+ // Continue with other accounts
+ }
+ }
+
+ logger.log("Completed GoCardless balance sync", {
+ familyId,
+ accountsUpdated
+ });
+
+ return {
+ success: true,
+ accountsUpdated,
+ message: `Successfully updated ${accountsUpdated} account balances`,
+ };
+ } catch (error) {
+ logger.error("Error syncing GoCardless balances", { error, familyId });
+ throw new Error(`Failed to sync GoCardless balances: ${error instanceof Error ? error.message : "Unknown error"}`);
+ } finally {
+ await prisma.$disconnect();
+ }
+ },
+});
+
+/**
+ * Process a single GoCardless transaction
+ */
+async function processGoCardlessTransaction(
+ transaction: Transaction,
+ connectedAccount: ConnectedAccountWithFinancialAccount,
+ familyId: string,
+ isPending: boolean,
+ prisma: PrismaClient
+) {
+ if (!connectedAccount.financialAccount) return;
+
+ // Create unique transaction ID for GoCardless
+ const transactionId = transaction.transactionId ||
+ createDeterministicId(transaction, connectedAccount.providerAccountId);
+
+ // Check if transaction already exists
+ const existingTransaction = await prisma.transaction.findFirst({
+ where: {
+ OR: [
+ { plaidTransactionId: transactionId },
+ {
+ AND: [
+ { accountId: connectedAccount.financialAccount.id },
+ { date: new Date(transaction.bookingDate || transaction.valueDate || new Date()) },
+ { amount: Math.abs(parseFloat(transaction.transactionAmount.amount)) },
+ { description: transaction.remittanceInformationUnstructured || "GoCardless Transaction" },
+ ],
+ },
+ ],
+ },
+ });
+
+ if (existingTransaction) return;
+
+ // Determine transaction type based on amount
+ const amount = parseFloat(transaction.transactionAmount.amount);
+ const transactionType = amount < 0 ? "EXPENSE" : "INCOME";
+
+ // Create transaction
+ await prisma.transaction.create({
+ data: {
+ date: new Date(transaction.bookingDate || transaction.valueDate || new Date()),
+ description: transaction.remittanceInformationUnstructured ||
+ transaction.remittanceInformationStructured ||
+ "GoCardless Transaction",
+ merchant: transaction.creditorName || transaction.debtorName || null,
+ amount: Math.abs(amount),
+ type: transactionType,
+ status: "NEEDS_CATEGORIZATION",
+ accountId: connectedAccount.financialAccount.id,
+ familyId,
+ // GoCardless-specific fields stored in existing Plaid fields
+ plaidTransactionId: transactionId,
+ plaidCategory: transaction.bankTransactionCode ? [transaction.bankTransactionCode] : [],
+ plaidSubcategory: transaction.proprietaryBankTransactionCode || null,
+ pending: isPending,
+ iso_currency_code: transaction.transactionAmount.currency,
+ tags: [transactionId],
+ },
+ });
+}
+
+/**
+ * Create a deterministic ID for GoCardless transactions
+ */
+function createDeterministicId(transaction: Transaction, accountId: string): string {
+ const baseString = `${accountId}-${transaction.transactionAmount.amount}-${transaction.bookingDate || transaction.valueDate}-${transaction.remittanceInformationUnstructured || ""}`;
+
+ // Simple hash function
+ let hash = 0;
+ for (let i = 0; i < baseString.length; i++) {
+ const char = baseString.charCodeAt(i);
+ hash = ((hash << 5) - hash) + char;
+ hash = hash & hash; // Convert to 32-bit integer
+ }
+
+ return `gocardless-${Math.abs(hash)}`;
+}
+
+/**
+ * Scheduled task to automatically sync GoCardless balances daily
+ */
+export const scheduledGoCardlessBalanceSync = schedules.task({
+ id: "scheduled-gocardless-balance-sync",
+ cron: "0 6 * * *", // Run daily at 6 AM
+ run: async () => {
+ const prisma = getPrismaClient();
+
+ logger.log("Starting scheduled GoCardless balance sync for all families");
+
+ try {
+ // Get all families with GoCardless connections
+ const familiesWithGoCardless = await prisma.family.findMany({
+ where: {
+ connectedAccounts: {
+ some: {
+ provider: "GOCARDLESS",
+ },
+ },
+ },
+ select: {
+ id: true,
+ name: true,
+ },
+ });
+
+ logger.log("Found families with GoCardless connections", {
+ familyCount: familiesWithGoCardless.length
+ });
+
+ // Trigger balance sync for each family
+ for (const family of familiesWithGoCardless) {
+ await syncGoCardlessBalances.trigger({
+ familyId: family.id,
+ });
+ }
+
+ return {
+ success: true,
+ familiesProcessed: familiesWithGoCardless.length,
+ message: `Triggered balance sync for ${familiesWithGoCardless.length} families`,
+ };
+ } catch (error) {
+ logger.error("Error in scheduled GoCardless balance sync", { error });
+ throw error;
+ } finally {
+ await prisma.$disconnect();
+ }
+ },
+});
+
+/**
+ * Scheduled task to automatically import new GoCardless transactions daily
+ */
+export const scheduledGoCardlessTransactionImport = schedules.task({
+ id: "scheduled-gocardless-transaction-import",
+ cron: "0 7 * * *", // Run daily at 7 AM
+ run: async () => {
+ const prisma = getPrismaClient();
+
+ logger.log("Starting scheduled GoCardless transaction import for all families");
+
+ try {
+ // Get all families with GoCardless connections
+ const familiesWithGoCardless = await prisma.family.findMany({
+ where: {
+ connectedAccounts: {
+ some: {
+ provider: "GOCARDLESS",
+ },
+ },
+ },
+ select: {
+ id: true,
+ name: true,
+ },
+ });
+
+ logger.log("Found families with GoCardless connections", {
+ familyCount: familiesWithGoCardless.length
+ });
+
+ // Trigger transaction import for each family (last 7 days)
+ const endDate = new Date();
+ const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+
+ for (const family of familiesWithGoCardless) {
+ await importGoCardlessTransactions.trigger({
+ familyId: family.id,
+ startDate: startDate.toISOString(),
+ endDate: endDate.toISOString(),
+ });
+ }
+
+ return {
+ success: true,
+ familiesProcessed: familiesWithGoCardless.length,
+ message: `Triggered transaction import for ${familiesWithGoCardless.length} families`,
+ };
+ } catch (error) {
+ logger.error("Error in scheduled GoCardless transaction import", { error });
+ throw error;
+ } finally {
+ await prisma.$disconnect();
+ }
+ },
+});
\ No newline at end of file
diff --git a/src/trigger/index.ts b/src/trigger/index.ts
new file mode 100644
index 00000000..6ecbfc4a
--- /dev/null
+++ b/src/trigger/index.ts
@@ -0,0 +1,21 @@
+// Export all Plaid trigger tasks
+export {
+ exchangePlaidPublicToken,
+ importPlaidTransactions,
+ syncPlaidBalances,
+ scheduledPlaidBalanceSync,
+ scheduledPlaidTransactionImport,
+} from "./plaid-tasks";
+
+// Export all GoCardless trigger tasks
+export {
+ completeGoCardlessConnection,
+ importGoCardlessTransactions,
+ syncGoCardlessBalances,
+ scheduledGoCardlessBalanceSync,
+ scheduledGoCardlessTransactionImport,
+} from "./gocardless-tasks";
+
+// Re-export for convenience
+export * from "./plaid-tasks";
+export * from "./gocardless-tasks";
\ No newline at end of file
diff --git a/src/trigger/plaid-tasks.ts b/src/trigger/plaid-tasks.ts
new file mode 100644
index 00000000..5bc28aec
--- /dev/null
+++ b/src/trigger/plaid-tasks.ts
@@ -0,0 +1,487 @@
+import { logger, task, schedules } from "@trigger.dev/sdk/v3";
+import { PrismaClient, AccountType } from "@/generated/prisma";
+import {
+ Configuration,
+ PlaidApi,
+ PlaidEnvironments,
+ ItemPublicTokenExchangeRequest,
+ AccountsGetRequest,
+ TransactionsGetRequest,
+ Account,
+} from "plaid";
+
+// Initialize Plaid client
+const configuration = new Configuration({
+ basePath: PlaidEnvironments.sandbox,
+ baseOptions: {
+ headers: {
+ "PLAID-CLIENT-ID": process.env.PLAID_CLIENT_ID!,
+ "PLAID-SECRET": process.env.PLAID_SECRET!,
+ "Plaid-Version": "2020-09-14",
+ },
+ },
+});
+
+const plaidClient = new PlaidApi(configuration);
+
+function getPrismaClient() {
+ return new PrismaClient();
+}
+
+// Map Plaid account type to our AccountType enum
+const getAccountType = (plaidType: string, plaidSubtype: string): AccountType => {
+ switch (plaidType) {
+ case "depository":
+ return plaidSubtype === "savings" ? AccountType.SAVINGS : AccountType.CHECKING;
+ case "credit":
+ return AccountType.CREDIT_CARD;
+ case "investment":
+ return AccountType.INVESTMENT;
+ case "loan":
+ return plaidSubtype === "mortgage" ? AccountType.MORTGAGE : AccountType.LOAN;
+ default:
+ return AccountType.OTHER;
+ }
+};
+
+/**
+ * Exchange Plaid public token for access token and create accounts
+ */
+export const exchangePlaidPublicToken = task({
+ id: "exchange-plaid-public-token",
+ maxDuration: 300, // 5 minutes
+ run: async (payload: { publicToken: string; familyId: string }) => {
+ const { publicToken, familyId } = payload;
+ const prisma = getPrismaClient();
+
+ logger.log("Starting Plaid public token exchange", { familyId });
+
+ try {
+ // Exchange public token for access token
+ const exchangeRequest: ItemPublicTokenExchangeRequest = {
+ public_token: publicToken,
+ };
+
+ const exchangeResponse = await plaidClient.itemPublicTokenExchange(exchangeRequest);
+ const accessToken = exchangeResponse.data.access_token;
+ const itemId = exchangeResponse.data.item_id;
+
+ logger.log("Successfully exchanged public token", { itemId });
+
+ // Get accounts information
+ const accountsRequest: AccountsGetRequest = {
+ access_token: accessToken,
+ };
+
+ const accountsResponse = await plaidClient.accountsGet(accountsRequest);
+ const accounts = accountsResponse.data.accounts;
+
+ logger.log("Retrieved accounts from Plaid", { accountCount: accounts.length });
+
+ // Create Plaid item record
+ const plaidItem = await prisma.plaidItem.create({
+ data: {
+ accessToken,
+ itemId,
+ institutionId: accountsResponse.data.item.institution_id || null,
+ familyId,
+ },
+ });
+
+ // Store accounts in database
+ const createdAccounts = await Promise.all(
+ accounts.map(async (account) => {
+ const accountType = getAccountType(account.type, account.subtype || "");
+
+ // Create financial account in our database
+ const financialAccount = await prisma.financialAccount.create({
+ data: {
+ name: account.name,
+ type: accountType,
+ balance: account.balances.current || 0,
+ currency: account.balances.iso_currency_code || "USD",
+ institution: accountsResponse.data.item.institution_id || null,
+ accountNumber: account.mask ? `****${account.mask}` : null,
+ familyId,
+ },
+ });
+
+ // Create Plaid account mapping
+ await prisma.plaidAccount.create({
+ data: {
+ plaidAccountId: account.account_id,
+ accountName: account.name,
+ accountType: account.type,
+ accountSubtype: account.subtype || null,
+ mask: account.mask || null,
+ plaidItemId: plaidItem.id,
+ financialAccountId: financialAccount.id,
+ },
+ });
+
+ return {
+ ...financialAccount,
+ plaidAccountId: account.account_id,
+ };
+ })
+ );
+
+ logger.log("Successfully created accounts", {
+ accountCount: createdAccounts.length,
+ accountIds: createdAccounts.map(a => a.id)
+ });
+
+ return {
+ success: true,
+ accounts: createdAccounts,
+ message: `Successfully connected ${accounts.length} accounts`,
+ };
+ } catch (error) {
+ logger.error("Error exchanging Plaid public token", { error, familyId });
+ throw new Error(`Failed to connect accounts: ${error instanceof Error ? error.message : "Unknown error"}`);
+ } finally {
+ await prisma.$disconnect();
+ }
+ },
+});
+
+/**
+ * Import transactions from all connected Plaid accounts
+ */
+export const importPlaidTransactions = task({
+ id: "import-plaid-transactions",
+ maxDuration: 600, // 10 minutes for large transaction imports
+ run: async (payload: {
+ familyId: string;
+ startDate?: string;
+ endDate?: string;
+ }) => {
+ const { familyId, startDate, endDate } = payload;
+ const prisma = getPrismaClient();
+
+ logger.log("Starting Plaid transaction import", { familyId, startDate, endDate });
+
+ try {
+ // Get all Plaid access tokens for this family
+ const plaidItems = await prisma.plaidItem.findMany({
+ where: { familyId },
+ select: {
+ accessToken: true,
+ itemId: true,
+ id: true,
+ },
+ });
+
+ if (plaidItems.length === 0) {
+ throw new Error("No Plaid accounts connected");
+ }
+
+ logger.log("Found Plaid items", { itemCount: plaidItems.length });
+
+ let totalTransactions = 0;
+
+ for (const item of plaidItems) {
+ logger.log("Processing Plaid item", { itemId: item.itemId });
+
+ // Get accounts for this item
+ const accountsRequest: AccountsGetRequest = {
+ access_token: item.accessToken,
+ };
+
+ const accountsResponse = await plaidClient.accountsGet(accountsRequest);
+ const accounts = accountsResponse.data.accounts;
+
+ // Set date range (default to last 30 days)
+ const end = endDate ? new Date(endDate) : new Date();
+ const start = startDate ? new Date(startDate) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
+
+ // Get transactions
+ const transactionsRequest: TransactionsGetRequest = {
+ access_token: item.accessToken,
+ start_date: start.toISOString().split("T")[0],
+ end_date: end.toISOString().split("T")[0],
+ };
+
+ const transactionsResponse = await plaidClient.transactionsGet(transactionsRequest);
+ const transactions = transactionsResponse.data.transactions;
+
+ logger.log("Retrieved transactions from Plaid", {
+ itemId: item.itemId,
+ transactionCount: transactions.length
+ });
+
+ // Import transactions
+ for (const transaction of transactions) {
+ // Find corresponding financial account using Plaid account mapping
+ const plaidAccountRecord = await prisma.plaidAccount.findFirst({
+ where: {
+ plaidAccountId: transaction.account_id,
+ plaidItem: {
+ familyId,
+ },
+ },
+ include: {
+ financialAccount: true,
+ },
+ });
+
+ if (!plaidAccountRecord?.financialAccount) {
+ logger.warn("No financial account found for Plaid account", {
+ plaidAccountId: transaction.account_id
+ });
+ continue;
+ }
+
+ const financialAccount = plaidAccountRecord.financialAccount;
+
+ // Check if transaction already exists
+ const existingTransaction = await prisma.transaction.findFirst({
+ where: {
+ plaidTransactionId: transaction.transaction_id,
+ },
+ });
+
+ if (existingTransaction) continue;
+
+ // Determine transaction type
+ const transactionType = transaction.amount < 0 ? "EXPENSE" : "INCOME";
+
+ // Create transaction with enhanced Plaid data
+ await prisma.transaction.create({
+ data: {
+ date: new Date(transaction.date),
+ description: transaction.name,
+ merchant: transaction.merchant_name || transaction.name,
+ amount: Math.abs(transaction.amount),
+ type: transactionType,
+ status: "NEEDS_CATEGORIZATION",
+ accountId: financialAccount.id,
+ familyId,
+ // Enhanced Plaid-specific fields
+ plaidTransactionId: transaction.transaction_id,
+ plaidCategory: transaction.category || [],
+ plaidSubcategory: transaction.category?.[0] || null,
+ merchantLogo: transaction.logo_url || null,
+ location: transaction.location ? {
+ address: transaction.location.address,
+ city: transaction.location.city,
+ region: transaction.location.region,
+ postal_code: transaction.location.postal_code,
+ country: transaction.location.country,
+ lat: transaction.location.lat,
+ lon: transaction.location.lon,
+ } : null,
+ pending: transaction.pending,
+ authorizedDate: transaction.authorized_date ? new Date(transaction.authorized_date) : null,
+ iso_currency_code: transaction.iso_currency_code,
+ tags: [transaction.transaction_id],
+ },
+ });
+
+ totalTransactions++;
+ }
+ }
+
+ logger.log("Completed Plaid transaction import", {
+ familyId,
+ totalTransactions
+ });
+
+ return {
+ success: true,
+ transactionsImported: totalTransactions,
+ message: `Successfully imported ${totalTransactions} transactions`,
+ };
+ } catch (error) {
+ logger.error("Error importing Plaid transactions", { error, familyId });
+ throw new Error(`Failed to import transactions: ${error instanceof Error ? error.message : "Unknown error"}`);
+ } finally {
+ await prisma.$disconnect();
+ }
+ },
+});
+
+/**
+ * Sync account balances from Plaid
+ */
+export const syncPlaidBalances = task({
+ id: "sync-plaid-balances",
+ maxDuration: 300, // 5 minutes
+ run: async (payload: { familyId: string }) => {
+ const { familyId } = payload;
+ const prisma = getPrismaClient();
+
+ logger.log("Starting Plaid balance sync", { familyId });
+
+ try {
+ // Get all Plaid access tokens for this family
+ const plaidItems = await prisma.plaidItem.findMany({
+ where: { familyId },
+ select: {
+ accessToken: true,
+ itemId: true,
+ id: true,
+ },
+ });
+
+ logger.log("Found Plaid items for balance sync", { itemCount: plaidItems.length });
+
+ let accountsUpdated = 0;
+
+ for (const item of plaidItems) {
+ const accountsRequest: AccountsGetRequest = {
+ access_token: item.accessToken,
+ };
+
+ const accountsResponse = await plaidClient.accountsGet(accountsRequest);
+ const accounts = accountsResponse.data.accounts;
+
+ for (const account of accounts) {
+ // Find corresponding financial account using Plaid account mapping
+ const plaidAccountRecord = await prisma.plaidAccount.findFirst({
+ where: {
+ plaidAccountId: account.account_id,
+ plaidItem: {
+ familyId,
+ },
+ },
+ include: {
+ financialAccount: true,
+ },
+ });
+
+ if (plaidAccountRecord?.financialAccount) {
+ await prisma.financialAccount.update({
+ where: { id: plaidAccountRecord.financialAccount.id },
+ data: {
+ balance: account.balances.current || 0,
+ },
+ });
+ accountsUpdated++;
+ }
+ }
+ }
+
+ logger.log("Completed Plaid balance sync", {
+ familyId,
+ accountsUpdated
+ });
+
+ return {
+ success: true,
+ accountsUpdated,
+ message: `Successfully updated ${accountsUpdated} account balances`,
+ };
+ } catch (error) {
+ logger.error("Error syncing Plaid balances", { error, familyId });
+ throw new Error(`Failed to sync account balances: ${error instanceof Error ? error.message : "Unknown error"}`);
+ } finally {
+ await prisma.$disconnect();
+ }
+ },
+});
+
+/**
+ * Scheduled task to automatically sync Plaid balances daily
+ */
+export const scheduledPlaidBalanceSync = schedules.task({
+ id: "scheduled-plaid-balance-sync",
+ cron: "0 6 * * *", // Run daily at 6 AM
+ run: async (payload) => {
+ const prisma = getPrismaClient();
+
+ logger.log("Starting scheduled Plaid balance sync for all families");
+
+ try {
+ // Get all families with Plaid connections
+ const familiesWithPlaid = await prisma.family.findMany({
+ where: {
+ plaidItems: {
+ some: {},
+ },
+ },
+ select: {
+ id: true,
+ name: true,
+ },
+ });
+
+ logger.log("Found families with Plaid connections", {
+ familyCount: familiesWithPlaid.length
+ });
+
+ // Trigger balance sync for each family
+ for (const family of familiesWithPlaid) {
+ await syncPlaidBalances.trigger({
+ familyId: family.id,
+ });
+ }
+
+ return {
+ success: true,
+ familiesProcessed: familiesWithPlaid.length,
+ message: `Triggered balance sync for ${familiesWithPlaid.length} families`,
+ };
+ } catch (error) {
+ logger.error("Error in scheduled Plaid balance sync", { error });
+ throw error;
+ } finally {
+ await prisma.$disconnect();
+ }
+ },
+});
+
+/**
+ * Scheduled task to automatically import new transactions daily
+ */
+export const scheduledPlaidTransactionImport = schedules.task({
+ id: "scheduled-plaid-transaction-import",
+ cron: "0 7 * * *", // Run daily at 7 AM
+ run: async (payload) => {
+ const prisma = getPrismaClient();
+
+ logger.log("Starting scheduled Plaid transaction import for all families");
+
+ try {
+ // Get all families with Plaid connections
+ const familiesWithPlaid = await prisma.family.findMany({
+ where: {
+ plaidItems: {
+ some: {},
+ },
+ },
+ select: {
+ id: true,
+ name: true,
+ },
+ });
+
+ logger.log("Found families with Plaid connections", {
+ familyCount: familiesWithPlaid.length
+ });
+
+ // Trigger transaction import for each family (last 7 days)
+ const endDate = new Date();
+ const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
+
+ for (const family of familiesWithPlaid) {
+ await importPlaidTransactions.trigger({
+ familyId: family.id,
+ startDate: startDate.toISOString(),
+ endDate: endDate.toISOString(),
+ });
+ }
+
+ return {
+ success: true,
+ familiesProcessed: familiesWithPlaid.length,
+ message: `Triggered transaction import for ${familiesWithPlaid.length} families`,
+ };
+ } catch (error) {
+ logger.error("Error in scheduled Plaid transaction import", { error });
+ throw error;
+ } finally {
+ await prisma.$disconnect();
+ }
+ },
+});
\ No newline at end of file
From 384bea6582cec7cc09b2dca28d10e5998175d0ec Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 3 Jul 2025 10:09:30 +0000
Subject: [PATCH 2/5] Fix GoCardless schema: Use BankConnection and
ConnectedAccount correctly
Co-authored-by: christer.hagen
---
TRIGGER_INTEGRATION.md | 30 ++++++++++++++++++++----------
src/trigger/gocardless-tasks.ts | 31 ++++++++++++++++++++++++-------
2 files changed, 44 insertions(+), 17 deletions(-)
diff --git a/TRIGGER_INTEGRATION.md b/TRIGGER_INTEGRATION.md
index ef36538c..6859f11d 100644
--- a/TRIGGER_INTEGRATION.md
+++ b/TRIGGER_INTEGRATION.md
@@ -19,6 +19,7 @@ I've created trigger.dev task implementations for both Plaid and GoCardless:
- ✅ `src/trigger/plaid-tasks.ts` - Plaid operations
- ✅ `src/trigger/gocardless-tasks.ts` - GoCardless operations
+- ✅ **Schema Fixed**: Corrected GoCardless to use proper `BankConnection` → `ConnectedAccount` hierarchy
## Available Tasks
@@ -127,7 +128,15 @@ cron: "0 2 * * 0"
## Integration Steps
-### 1. Fix Import Issues
+### 1. Schema Structure (FIXED ✅)
+
+**Issue**: The original implementation incorrectly used `ConnectedAccount` for both the bank connection and individual accounts.
+
+**Solution**: Now properly uses the hierarchical schema:
+- `BankConnection` - Overall connection to GoCardless (equivalent to Plaid's `PlaidItem`)
+- `ConnectedAccount` - Individual accounts within that connection (equivalent to Plaid's `PlaidAccount`)
+
+### 2. Fix Import Issues
The trigger files have some import path issues that need to be resolved:
@@ -139,7 +148,7 @@ npm run db:generate
# import { PrismaClient, AccountType } from "@prisma/client";
```
-### 2. Update Your Existing Actions
+### 3. Update Your Existing Actions
Replace direct function calls with trigger.dev task triggers:
@@ -174,7 +183,7 @@ export async function exchangePublicToken(publicToken: string) {
}
```
-### 3. Update UI Components
+### 4. Update UI Components
Handle the async nature in your UI:
@@ -199,7 +208,7 @@ const handleConnect = async (publicToken: string) => {
};
```
-### 4. Environment Variables
+### 5. Environment Variables
Ensure all required environment variables are set:
@@ -216,7 +225,7 @@ GOCARDLESS_SECRET_KEY=your_gocardless_secret_key
TRIGGER_SECRET_KEY=your_trigger_secret_key
```
-### 5. Deploy Trigger Functions
+### 6. Deploy Trigger Functions
Deploy your trigger functions:
@@ -260,10 +269,11 @@ curl -X POST http://localhost:3040/api/trigger \
## Next Steps
-1. **Fix Import Paths**: Resolve the Prisma and trigger.dev import issues
-2. **Update Actions**: Replace synchronous calls with trigger.dev tasks
-3. **Update UI**: Handle async operations in your frontend
-4. **Deploy**: Deploy trigger functions to production
-5. **Monitor**: Set up alerts and monitoring
+1. ✅ **Schema Structure**: Fixed GoCardless to use proper BankConnection → ConnectedAccount hierarchy
+2. **Fix Import Paths**: Resolve the Prisma and trigger.dev import issues
+3. **Update Actions**: Replace synchronous calls with trigger.dev tasks
+4. **Update UI**: Handle async operations in your frontend
+5. **Deploy**: Deploy trigger functions to production
+6. **Monitor**: Set up alerts and monitoring
This architecture will make your financial data operations much more robust, scalable, and user-friendly!
\ No newline at end of file
diff --git a/src/trigger/gocardless-tasks.ts b/src/trigger/gocardless-tasks.ts
index 04c2376e..8582f8ff 100644
--- a/src/trigger/gocardless-tasks.ts
+++ b/src/trigger/gocardless-tasks.ts
@@ -313,12 +313,15 @@ export const completeGoCardlessConnection = task({
});
// Create bank connection record
- const bankConnection = await prisma.connectedAccount.create({
+ const bankConnection = await prisma.bankConnection.create({
data: {
provider: "GOCARDLESS",
accessToken: requisitionId, // For GoCardless, we use requisition ID as access token
+ itemId: requisitionId,
familyId,
institutionId: bank.institutionId?.gocardless || null,
+ institutionName: bank.displayName,
+ institutionCountry: bank.country,
},
});
@@ -454,16 +457,23 @@ export const importGoCardlessTransactions = task({
try {
// Get all GoCardless connections for this family
- const connections = await prisma.connectedAccount.findMany({
+ const bankConnections = await prisma.bankConnection.findMany({
where: {
familyId,
provider: "GOCARDLESS",
},
include: {
- financialAccount: true,
+ connectedAccounts: {
+ include: {
+ financialAccount: true,
+ },
+ },
},
});
+ // Flatten to get all connected accounts
+ const connections = bankConnections.flatMap((bc: any) => bc.connectedAccounts);
+
if (connections.length === 0) {
throw new Error("No GoCardless accounts connected");
}
@@ -559,16 +569,23 @@ export const syncGoCardlessBalances = task({
try {
// Get all GoCardless connections for this family
- const connections = await prisma.connectedAccount.findMany({
+ const bankConnections = await prisma.bankConnection.findMany({
where: {
familyId,
provider: "GOCARDLESS",
},
include: {
- financialAccount: true,
+ connectedAccounts: {
+ include: {
+ financialAccount: true,
+ },
+ },
},
});
+ // Flatten to get all connected accounts
+ const connections = bankConnections.flatMap((bc: any) => bc.connectedAccounts);
+
logger.log("Found GoCardless connections for balance sync", {
connectionCount: connections.length
});
@@ -724,7 +741,7 @@ export const scheduledGoCardlessBalanceSync = schedules.task({
// Get all families with GoCardless connections
const familiesWithGoCardless = await prisma.family.findMany({
where: {
- connectedAccounts: {
+ BankConnection: {
some: {
provider: "GOCARDLESS",
},
@@ -776,7 +793,7 @@ export const scheduledGoCardlessTransactionImport = schedules.task({
// Get all families with GoCardless connections
const familiesWithGoCardless = await prisma.family.findMany({
where: {
- connectedAccounts: {
+ BankConnection: {
some: {
provider: "GOCARDLESS",
},
From 4bf4ce37aa6f99d9ff0bd8c4864b54457b078b1c Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 3 Jul 2025 10:18:23 +0000
Subject: [PATCH 3/5] Add comprehensive Trigger.dev test button for async tasks
Co-authored-by: christer.hagen
---
TRIGGER_INTEGRATION.md | 30 ++-
TRIGGER_TEST_GUIDE.md | 87 +++++++
src/actions/trigger-test-actions.ts | 234 +++++++++++++++++
.../accounts/accounts-header-section.tsx | 2 +
.../financial/trigger-test-button.tsx | 244 ++++++++++++++++++
5 files changed, 592 insertions(+), 5 deletions(-)
create mode 100644 TRIGGER_TEST_GUIDE.md
create mode 100644 src/actions/trigger-test-actions.ts
create mode 100644 src/components/financial/trigger-test-button.tsx
diff --git a/TRIGGER_INTEGRATION.md b/TRIGGER_INTEGRATION.md
index 6859f11d..9e9bca76 100644
--- a/TRIGGER_INTEGRATION.md
+++ b/TRIGGER_INTEGRATION.md
@@ -267,13 +267,33 @@ curl -X POST http://localhost:3040/api/trigger \
-d '{"taskId": "import-plaid-transactions", "payload": {"familyId": "test-family"}}'
```
+## Test Button Added ✅
+
+I've added a **"Test Trigger.dev"** button to your Financial Accounts page that allows you to test all the trigger.dev functions:
+
+### **Location**: `/dashboard/financial`
+### **Features**:
+- ⚡ **Transaction Import Tests**: Test Plaid, GoCardless, or both providers (7-30 days)
+- 🔄 **Balance Sync Tests**: Test balance syncing for individual or both providers
+- 📊 **Real-time Feedback**: Shows task IDs and links to Trigger.dev dashboard
+- 🎯 **Loading States**: Visual feedback for each operation
+- 🔗 **Dashboard Links**: Direct links to monitor tasks in Trigger.dev
+
+### **How to Test**:
+1. Navigate to `/dashboard/financial`
+2. Click the **"⚡ Test Trigger.dev"** button (next to Import Transactions)
+3. Choose any test operation from the dropdown
+4. Watch the toast notifications for task IDs and dashboard links
+5. Monitor progress in the Trigger.dev dashboard
+
## Next Steps
1. ✅ **Schema Structure**: Fixed GoCardless to use proper BankConnection → ConnectedAccount hierarchy
-2. **Fix Import Paths**: Resolve the Prisma and trigger.dev import issues
-3. **Update Actions**: Replace synchronous calls with trigger.dev tasks
-4. **Update UI**: Handle async operations in your frontend
-5. **Deploy**: Deploy trigger functions to production
-6. **Monitor**: Set up alerts and monitoring
+2. ✅ **Test Button**: Added comprehensive test interface on financial page
+3. **Fix Import Paths**: Resolve the Prisma and trigger.dev import issues
+4. **Update Actions**: Replace synchronous calls with trigger.dev tasks
+5. **Update UI**: Handle async operations in your frontend
+6. **Deploy**: Deploy trigger functions to production
+7. **Monitor**: Set up alerts and monitoring
This architecture will make your financial data operations much more robust, scalable, and user-friendly!
\ No newline at end of file
diff --git a/TRIGGER_TEST_GUIDE.md b/TRIGGER_TEST_GUIDE.md
new file mode 100644
index 00000000..2f1948a4
--- /dev/null
+++ b/TRIGGER_TEST_GUIDE.md
@@ -0,0 +1,87 @@
+# Testing Trigger.dev Functions - Quick Guide
+
+## 🎯 What You've Got
+
+A **comprehensive test button** on your Financial Accounts page that lets you test all trigger.dev functions without any coding.
+
+## 📍 Where to Find It
+
+1. Navigate to: **`/dashboard/financial`**
+2. Look for the **"⚡ Test Trigger.dev"** button in the top-right area
+3. It should be right next to your existing "Import Transactions" and "Connect Bank Account" buttons
+
+## 🧪 What You Can Test
+
+### **Transaction Import Tests**
+- **Plaid - Last 7 days**: Tests Plaid transaction import for the last week
+- **GoCardless - Last 7 days**: Tests GoCardless transaction import for the last week
+- **Both - Last 30 days**: Tests both providers in parallel for the last month
+
+### **Balance Sync Tests**
+- **Plaid Balances**: Tests Plaid account balance sync
+- **GoCardless Balances**: Tests GoCardless account balance sync
+- **Both Providers**: Tests both balance syncs in parallel
+
+## ✅ How to Test
+
+1. **Click** the "⚡ Test Trigger.dev" button
+2. **Choose** any test from the dropdown menu
+3. **Watch** for the success toast notification with:
+ - ✅ Task started confirmation
+ - 🆔 Unique task ID
+ - 🔗 Direct link to Trigger.dev dashboard
+4. **Click** the dashboard link to monitor progress in real-time
+5. **Check** your Trigger.dev dashboard to see the task running
+
+## 🔍 What Success Looks Like
+
+### **Immediate Feedback (in UI)**
+```
+✅ Plaid transaction import started (Task: run_01234567890)
+🔗 View in Trigger.dev Dashboard
+```
+
+### **In Trigger.dev Dashboard**
+- Task appears as "RUNNING" ⏳
+- Detailed logs show each step
+- Final status: "COMPLETED" ✅ or "FAILED" ❌
+- Execution time and any error details
+
+## 🚨 Troubleshooting
+
+### **If the button doesn't appear:**
+- Make sure you're on `/dashboard/financial`
+- Check that the component imported correctly
+- Verify trigger.dev integration is set up
+
+### **If tasks fail:**
+- Check environment variables are set
+- Verify database connections
+- Ensure Plaid/GoCardless credentials are valid
+- Check trigger.dev service is running
+
+### **If imports succeed but no data:**
+- Verify you have connected accounts
+- Check date ranges (may be no transactions in selected period)
+- Confirm account mappings are correct
+
+## 🔧 Behind the Scenes
+
+When you click a test, it:
+
+1. **Authenticates** your current user and family
+2. **Triggers** the background task in trigger.dev
+3. **Returns** immediately with task ID (async!)
+4. **Task runs** in background with full logging
+5. **Completes** with success/failure status
+
+This is exactly how the async architecture will work in production! 🚀
+
+## 📊 Monitoring Tips
+
+- **Watch the logs** for detailed step-by-step progress
+- **Check task duration** to understand performance
+- **Monitor failures** to identify any issues
+- **Test with different date ranges** to verify flexibility
+
+Happy testing! 🎉
\ No newline at end of file
diff --git a/src/actions/trigger-test-actions.ts b/src/actions/trigger-test-actions.ts
new file mode 100644
index 00000000..1c81da98
--- /dev/null
+++ b/src/actions/trigger-test-actions.ts
@@ -0,0 +1,234 @@
+"use server";
+
+import { getCurrentAppUser } from "./user-actions";
+
+// Import the trigger.dev tasks
+import {
+ importPlaidTransactions,
+ syncPlaidBalances,
+ exchangePlaidPublicToken
+} from "@/trigger/plaid-tasks";
+
+import {
+ importGoCardlessTransactions,
+ syncGoCardlessBalances,
+ completeGoCardlessConnection
+} from "@/trigger/gocardless-tasks";
+
+async function getActiveFamilyId(): Promise {
+ const appUser = await getCurrentAppUser();
+ return appUser?.familyMemberships[0]?.familyId || null;
+}
+
+/**
+ * Test trigger.dev Plaid transaction import
+ */
+export async function testTriggerPlaidTransactionImport(days: number = 30) {
+ try {
+ const familyId = await getActiveFamilyId();
+ if (!familyId) {
+ throw new Error("User not authenticated or no family found");
+ }
+
+ const endDate = new Date();
+ const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
+
+ const taskRun = await importPlaidTransactions.trigger({
+ familyId,
+ startDate: startDate.toISOString(),
+ endDate: endDate.toISOString(),
+ });
+
+ return {
+ success: true,
+ taskId: taskRun.id,
+ message: `✅ Plaid transaction import started (Task: ${taskRun.id})`,
+ url: `https://trigger.dev/dashboard/runs/${taskRun.id}`, // Assuming standard trigger.dev dashboard URL
+ };
+ } catch (error) {
+ console.error("Error triggering Plaid transaction import:", error);
+ throw new Error(`Failed to trigger Plaid transaction import: ${error instanceof Error ? error.message : "Unknown error"}`);
+ }
+}
+
+/**
+ * Test trigger.dev GoCardless transaction import
+ */
+export async function testTriggerGoCardlessTransactionImport(days: number = 30) {
+ try {
+ const familyId = await getActiveFamilyId();
+ if (!familyId) {
+ throw new Error("User not authenticated or no family found");
+ }
+
+ const endDate = new Date();
+ const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
+
+ const taskRun = await importGoCardlessTransactions.trigger({
+ familyId,
+ startDate: startDate.toISOString(),
+ endDate: endDate.toISOString(),
+ });
+
+ return {
+ success: true,
+ taskId: taskRun.id,
+ message: `✅ GoCardless transaction import started (Task: ${taskRun.id})`,
+ url: `https://trigger.dev/dashboard/runs/${taskRun.id}`,
+ };
+ } catch (error) {
+ console.error("Error triggering GoCardless transaction import:", error);
+ throw new Error(`Failed to trigger GoCardless transaction import: ${error instanceof Error ? error.message : "Unknown error"}`);
+ }
+}
+
+/**
+ * Test trigger.dev Plaid balance sync
+ */
+export async function testTriggerPlaidBalanceSync() {
+ try {
+ const familyId = await getActiveFamilyId();
+ if (!familyId) {
+ throw new Error("User not authenticated or no family found");
+ }
+
+ const taskRun = await syncPlaidBalances.trigger({
+ familyId,
+ });
+
+ return {
+ success: true,
+ taskId: taskRun.id,
+ message: `✅ Plaid balance sync started (Task: ${taskRun.id})`,
+ url: `https://trigger.dev/dashboard/runs/${taskRun.id}`,
+ };
+ } catch (error) {
+ console.error("Error triggering Plaid balance sync:", error);
+ throw new Error(`Failed to trigger Plaid balance sync: ${error instanceof Error ? error.message : "Unknown error"}`);
+ }
+}
+
+/**
+ * Test trigger.dev GoCardless balance sync
+ */
+export async function testTriggerGoCardlessBalanceSync() {
+ try {
+ const familyId = await getActiveFamilyId();
+ if (!familyId) {
+ throw new Error("User not authenticated or no family found");
+ }
+
+ const taskRun = await syncGoCardlessBalances.trigger({
+ familyId,
+ });
+
+ return {
+ success: true,
+ taskId: taskRun.id,
+ message: `✅ GoCardless balance sync started (Task: ${taskRun.id})`,
+ url: `https://trigger.dev/dashboard/runs/${taskRun.id}`,
+ };
+ } catch (error) {
+ console.error("Error triggering GoCardless balance sync:", error);
+ throw new Error(`Failed to trigger GoCardless balance sync: ${error instanceof Error ? error.message : "Unknown error"}`);
+ }
+}
+
+/**
+ * Test triggering both providers in parallel
+ */
+export async function testTriggerBothProvidersTransactionImport(days: number = 30) {
+ try {
+ const familyId = await getActiveFamilyId();
+ if (!familyId) {
+ throw new Error("User not authenticated or no family found");
+ }
+
+ const endDate = new Date();
+ const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
+
+ // Trigger both providers in parallel
+ const [plaidTaskRun, goCardlessTaskRun] = await Promise.allSettled([
+ importPlaidTransactions.trigger({
+ familyId,
+ startDate: startDate.toISOString(),
+ endDate: endDate.toISOString(),
+ }),
+ importGoCardlessTransactions.trigger({
+ familyId,
+ startDate: startDate.toISOString(),
+ endDate: endDate.toISOString(),
+ }),
+ ]);
+
+ const results = [];
+
+ if (plaidTaskRun.status === "fulfilled") {
+ results.push(`Plaid: ${plaidTaskRun.value.id}`);
+ } else {
+ results.push(`Plaid: Failed - ${plaidTaskRun.reason}`);
+ }
+
+ if (goCardlessTaskRun.status === "fulfilled") {
+ results.push(`GoCardless: ${goCardlessTaskRun.value.id}`);
+ } else {
+ results.push(`GoCardless: Failed - ${goCardlessTaskRun.reason}`);
+ }
+
+ return {
+ success: true,
+ message: `✅ Both providers triggered: ${results.join(", ")}`,
+ results: {
+ plaid: plaidTaskRun.status === "fulfilled" ? plaidTaskRun.value.id : null,
+ gocardless: goCardlessTaskRun.status === "fulfilled" ? goCardlessTaskRun.value.id : null,
+ },
+ };
+ } catch (error) {
+ console.error("Error triggering both providers:", error);
+ throw new Error(`Failed to trigger both providers: ${error instanceof Error ? error.message : "Unknown error"}`);
+ }
+}
+
+/**
+ * Test triggering both providers balance sync in parallel
+ */
+export async function testTriggerBothProvidersBalanceSync() {
+ try {
+ const familyId = await getActiveFamilyId();
+ if (!familyId) {
+ throw new Error("User not authenticated or no family found");
+ }
+
+ // Trigger both providers in parallel
+ const [plaidTaskRun, goCardlessTaskRun] = await Promise.allSettled([
+ syncPlaidBalances.trigger({ familyId }),
+ syncGoCardlessBalances.trigger({ familyId }),
+ ]);
+
+ const results = [];
+
+ if (plaidTaskRun.status === "fulfilled") {
+ results.push(`Plaid: ${plaidTaskRun.value.id}`);
+ } else {
+ results.push(`Plaid: Failed - ${plaidTaskRun.reason}`);
+ }
+
+ if (goCardlessTaskRun.status === "fulfilled") {
+ results.push(`GoCardless: ${goCardlessTaskRun.value.id}`);
+ } else {
+ results.push(`GoCardless: Failed - ${goCardlessTaskRun.reason}`);
+ }
+
+ return {
+ success: true,
+ message: `✅ Both providers balance sync triggered: ${results.join(", ")}`,
+ results: {
+ plaid: plaidTaskRun.status === "fulfilled" ? plaidTaskRun.value.id : null,
+ gocardless: goCardlessTaskRun.status === "fulfilled" ? goCardlessTaskRun.value.id : null,
+ },
+ };
+ } catch (error) {
+ console.error("Error triggering both providers balance sync:", error);
+ throw new Error(`Failed to trigger both providers balance sync: ${error instanceof Error ? error.message : "Unknown error"}`);
+ }
+}
\ No newline at end of file
diff --git a/src/components/accounts/accounts-header-section.tsx b/src/components/accounts/accounts-header-section.tsx
index 77ca4a80..ea970818 100644
--- a/src/components/accounts/accounts-header-section.tsx
+++ b/src/components/accounts/accounts-header-section.tsx
@@ -2,6 +2,7 @@
import { PlaidLinkModal } from "@/components/financial/plaid-link-modal";
import { TransactionImportButton } from "@/components/financial/transaction-import-button";
+import { TriggerTestButton } from "@/components/financial/trigger-test-button";
import { useRouter } from "next/navigation";
export function AccountsHeaderSection() {
@@ -22,6 +23,7 @@ export function AccountsHeaderSection() {
diff --git a/src/components/financial/trigger-test-button.tsx b/src/components/financial/trigger-test-button.tsx
new file mode 100644
index 00000000..cce3ff38
--- /dev/null
+++ b/src/components/financial/trigger-test-button.tsx
@@ -0,0 +1,244 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Zap,
+ Play,
+ Calendar,
+ Sync,
+ AlertCircle,
+ CheckCircle,
+ ExternalLink,
+ Clock,
+ Coins
+} from "lucide-react";
+import {
+ testTriggerPlaidTransactionImport,
+ testTriggerGoCardlessTransactionImport,
+ testTriggerPlaidBalanceSync,
+ testTriggerGoCardlessBalanceSync,
+ testTriggerBothProvidersTransactionImport,
+ testTriggerBothProvidersBalanceSync
+} from "@/actions/trigger-test-actions";
+import { toast } from "sonner";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+ DropdownMenuLabel,
+} from "@/components/ui/dropdown-menu";
+
+interface TriggerTestButtonProps {
+ onSuccess?: () => void;
+ variant?: "default" | "outline" | "ghost";
+ size?: "default" | "sm" | "lg";
+}
+
+export function TriggerTestButton({
+ onSuccess,
+ variant = "outline",
+ size = "default"
+}: TriggerTestButtonProps) {
+ const [isLoading, setIsLoading] = useState(false);
+ const [activeOperation, setActiveOperation] = useState(null);
+
+ const handleOperation = async (
+ operation: () => Promise,
+ operationName: string
+ ) => {
+ try {
+ setIsLoading(true);
+ setActiveOperation(operationName);
+
+ const result = await operation();
+
+ if (result.success) {
+ toast.success(result.message, {
+ icon: ,
+ description: result.url ? (
+
+ ) : undefined,
+ duration: 8000,
+ });
+ onSuccess?.();
+ } else {
+ throw new Error("Operation failed");
+ }
+ } catch (error) {
+ console.error(`Error in ${operationName}:`, error);
+ toast.error(`Failed to trigger ${operationName}`, {
+ icon: ,
+ description: error instanceof Error ? error.message : "Unknown error",
+ });
+ } finally {
+ setIsLoading(false);
+ setActiveOperation(null);
+ }
+ };
+
+ const getLoadingText = () => {
+ if (!isLoading) return "Test Trigger.dev";
+ return activeOperation ? `${activeOperation}...` : "Starting...";
+ };
+
+ return (
+
+
+
+
+
+
+
+ Trigger.dev Tests
+
+
+ Test async background tasks
+
+
+
+ {/* Transaction Import Tests */}
+
+ Transaction Import
+
+
+ handleOperation(
+ () => testTriggerPlaidTransactionImport(7),
+ "Plaid Import (7d)"
+ )}
+ disabled={isLoading}
+ className="flex items-center justify-between"
+ >
+
+
+ Plaid - Last 7 days
+
+ {isLoading && activeOperation === "Plaid Import (7d)" && (
+
+ )}
+
+
+ handleOperation(
+ () => testTriggerGoCardlessTransactionImport(7),
+ "GoCardless Import (7d)"
+ )}
+ disabled={isLoading}
+ className="flex items-center justify-between"
+ >
+
+
+ GoCardless - Last 7 days
+
+ {isLoading && activeOperation === "GoCardless Import (7d)" && (
+
+ )}
+
+
+ handleOperation(
+ () => testTriggerBothProvidersTransactionImport(30),
+ "Both Providers Import (30d)"
+ )}
+ disabled={isLoading}
+ className="flex items-center justify-between font-medium"
+ >
+
+
+ Both - Last 30 days
+
+ {isLoading && activeOperation === "Both Providers Import (30d)" && (
+
+ )}
+
+
+
+
+ {/* Balance Sync Tests */}
+
+ Balance Sync
+
+
+ handleOperation(
+ () => testTriggerPlaidBalanceSync(),
+ "Plaid Balance Sync"
+ )}
+ disabled={isLoading}
+ className="flex items-center justify-between"
+ >
+
+
+ Plaid Balances
+
+ {isLoading && activeOperation === "Plaid Balance Sync" && (
+
+ )}
+
+
+ handleOperation(
+ () => testTriggerGoCardlessBalanceSync(),
+ "GoCardless Balance Sync"
+ )}
+ disabled={isLoading}
+ className="flex items-center justify-between"
+ >
+
+
+ GoCardless Balances
+
+ {isLoading && activeOperation === "GoCardless Balance Sync" && (
+
+ )}
+
+
+ handleOperation(
+ () => testTriggerBothProvidersBalanceSync(),
+ "Both Providers Balance Sync"
+ )}
+ disabled={isLoading}
+ className="flex items-center justify-between font-medium"
+ >
+
+
+ Both Providers
+
+ {isLoading && activeOperation === "Both Providers Balance Sync" && (
+
+ )}
+
+
+
+
+
+ ⚡ Tasks run async in background
+
+ 📊 Monitor in Trigger.dev Dashboard
+
+
+
+ );
+}
\ No newline at end of file
From aaa0dd95ef644dcd2fd7cd6fb2a646d75b5022e9 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 3 Jul 2025 10:56:15 +0000
Subject: [PATCH 4/5] Add bank connection tests for Plaid and GoCardless
Co-authored-by: christer.hagen
---
TRIGGER_INTEGRATION.md | 3 +-
TRIGGER_TEST_GUIDE.md | 11 ++
src/actions/trigger-test-actions.ts | 135 ++++++++++++++++++
.../financial/trigger-test-button.tsx | 69 ++++++++-
4 files changed, 215 insertions(+), 3 deletions(-)
diff --git a/TRIGGER_INTEGRATION.md b/TRIGGER_INTEGRATION.md
index 9e9bca76..e23609da 100644
--- a/TRIGGER_INTEGRATION.md
+++ b/TRIGGER_INTEGRATION.md
@@ -274,7 +274,8 @@ I've added a **"Test Trigger.dev"** button to your Financial Accounts page that
### **Location**: `/dashboard/financial`
### **Features**:
- ⚡ **Transaction Import Tests**: Test Plaid, GoCardless, or both providers (7-30 days)
-- 🔄 **Balance Sync Tests**: Test balance syncing for individual or both providers
+- 🔄 **Balance Sync Tests**: Test balance syncing for individual or both providers
+- 🔗 **Bank Connection Tests**: Test async connection flows with mock data (NEW!)
- 📊 **Real-time Feedback**: Shows task IDs and links to Trigger.dev dashboard
- 🎯 **Loading States**: Visual feedback for each operation
- 🔗 **Dashboard Links**: Direct links to monitor tasks in Trigger.dev
diff --git a/TRIGGER_TEST_GUIDE.md b/TRIGGER_TEST_GUIDE.md
index 2f1948a4..daf18993 100644
--- a/TRIGGER_TEST_GUIDE.md
+++ b/TRIGGER_TEST_GUIDE.md
@@ -22,6 +22,11 @@ A **comprehensive test button** on your Financial Accounts page that lets you te
- **GoCardless Balances**: Tests GoCardless account balance sync
- **Both Providers**: Tests both balance syncs in parallel
+### **Bank Connection Tests (Mock Data)**
+- **Plaid Connection**: Tests async Plaid account connection flow
+- **GoCardless Connection**: Tests async GoCardless account connection flow
+- **Both Connections**: Tests both connection flows in parallel
+
## ✅ How to Test
1. **Click** the "⚡ Test Trigger.dev" button
@@ -65,6 +70,12 @@ A **comprehensive test button** on your Financial Accounts page that lets you te
- Check date ranges (may be no transactions in selected period)
- Confirm account mappings are correct
+### **For connection tests:**
+- These use **mock data** and won't create real bank connections
+- They test the async trigger.dev task execution flow
+- Use them to verify the connection tasks work without real bank auth
+- Real connections still need to go through Plaid Link/GoCardless auth flows
+
## 🔧 Behind the Scenes
When you click a test, it:
diff --git a/src/actions/trigger-test-actions.ts b/src/actions/trigger-test-actions.ts
index 1c81da98..c49598cf 100644
--- a/src/actions/trigger-test-actions.ts
+++ b/src/actions/trigger-test-actions.ts
@@ -15,6 +15,9 @@ import {
completeGoCardlessConnection
} from "@/trigger/gocardless-tasks";
+import { getBankById } from "@/data/banks";
+import type { BankInfo } from "@/data/banks";
+
async function getActiveFamilyId(): Promise {
const appUser = await getCurrentAppUser();
return appUser?.familyMemberships[0]?.familyId || null;
@@ -231,4 +234,136 @@ export async function testTriggerBothProvidersBalanceSync() {
console.error("Error triggering both providers balance sync:", error);
throw new Error(`Failed to trigger both providers balance sync: ${error instanceof Error ? error.message : "Unknown error"}`);
}
+}
+
+/**
+ * Test trigger.dev Plaid connection (with mock public token)
+ * NOTE: This uses a test public token for testing the async flow
+ */
+export async function testTriggerPlaidConnection() {
+ try {
+ const familyId = await getActiveFamilyId();
+ if (!familyId) {
+ throw new Error("User not authenticated or no family found");
+ }
+
+ // Mock public token for testing - in real usage this comes from Plaid Link
+ const mockPublicToken = "public-sandbox-test-token-" + Date.now();
+
+ const taskRun = await exchangePlaidPublicToken.trigger({
+ publicToken: mockPublicToken,
+ familyId,
+ });
+
+ return {
+ success: true,
+ taskId: taskRun.id,
+ message: `✅ Plaid connection test started (Task: ${taskRun.id})`,
+ url: `https://trigger.dev/dashboard/runs/${taskRun.id}`,
+ note: "Using mock public token for testing async flow",
+ };
+ } catch (error) {
+ console.error("Error triggering Plaid connection test:", error);
+ throw new Error(`Failed to trigger Plaid connection test: ${error instanceof Error ? error.message : "Unknown error"}`);
+ }
+}
+
+/**
+ * Test trigger.dev GoCardless connection (with mock data)
+ * NOTE: This uses mock bank and requisition data for testing the async flow
+ */
+export async function testTriggerGoCardlessConnection() {
+ try {
+ const familyId = await getActiveFamilyId();
+ if (!familyId) {
+ throw new Error("User not authenticated or no family found");
+ }
+
+ // Use real bank data for testing
+ const mockBank = getBankById("dnb");
+ if (!mockBank) {
+ throw new Error("Test bank not found - DNB bank should exist in banks data");
+ }
+
+ // Mock requisition ID for testing - in real usage this comes from GoCardless authorization
+ const mockRequisitionId = "req-test-" + Date.now();
+
+ const taskRun = await completeGoCardlessConnection.trigger({
+ requisitionId: mockRequisitionId,
+ bank: mockBank,
+ familyId,
+ });
+
+ return {
+ success: true,
+ taskId: taskRun.id,
+ message: `✅ GoCardless connection test started (Task: ${taskRun.id})`,
+ url: `https://trigger.dev/dashboard/runs/${taskRun.id}`,
+ note: "Using mock requisition data for testing async flow",
+ };
+ } catch (error) {
+ console.error("Error triggering GoCardless connection test:", error);
+ throw new Error(`Failed to trigger GoCardless connection test: ${error instanceof Error ? error.message : "Unknown error"}`);
+ }
+}
+
+/**
+ * Test triggering both provider connections in parallel
+ * NOTE: This uses mock data for testing the async connection flows
+ */
+export async function testTriggerBothProvidersConnection() {
+ try {
+ const familyId = await getActiveFamilyId();
+ if (!familyId) {
+ throw new Error("User not authenticated or no family found");
+ }
+
+ // Mock data for testing
+ const mockPublicToken = "public-sandbox-test-token-" + Date.now();
+ const mockRequisitionId = "req-test-" + Date.now();
+ const mockBank = getBankById("dnb");
+ if (!mockBank) {
+ throw new Error("Test bank not found - DNB bank should exist in banks data");
+ }
+
+ // Trigger both provider connections in parallel
+ const [plaidTaskRun, goCardlessTaskRun] = await Promise.allSettled([
+ exchangePlaidPublicToken.trigger({
+ publicToken: mockPublicToken,
+ familyId,
+ }),
+ completeGoCardlessConnection.trigger({
+ requisitionId: mockRequisitionId,
+ bank: mockBank,
+ familyId,
+ }),
+ ]);
+
+ const results = [];
+
+ if (plaidTaskRun.status === "fulfilled") {
+ results.push(`Plaid: ${plaidTaskRun.value.id}`);
+ } else {
+ results.push(`Plaid: Failed - ${plaidTaskRun.reason}`);
+ }
+
+ if (goCardlessTaskRun.status === "fulfilled") {
+ results.push(`GoCardless: ${goCardlessTaskRun.value.id}`);
+ } else {
+ results.push(`GoCardless: Failed - ${goCardlessTaskRun.reason}`);
+ }
+
+ return {
+ success: true,
+ message: `✅ Both provider connections triggered: ${results.join(", ")}`,
+ results: {
+ plaid: plaidTaskRun.status === "fulfilled" ? plaidTaskRun.value.id : null,
+ gocardless: goCardlessTaskRun.status === "fulfilled" ? goCardlessTaskRun.value.id : null,
+ },
+ note: "Using mock data for testing async connection flows",
+ };
+ } catch (error) {
+ console.error("Error triggering both provider connections:", error);
+ throw new Error(`Failed to trigger both provider connections: ${error instanceof Error ? error.message : "Unknown error"}`);
+ }
}
\ No newline at end of file
diff --git a/src/components/financial/trigger-test-button.tsx b/src/components/financial/trigger-test-button.tsx
index cce3ff38..034ff03b 100644
--- a/src/components/financial/trigger-test-button.tsx
+++ b/src/components/financial/trigger-test-button.tsx
@@ -11,7 +11,9 @@ import {
CheckCircle,
ExternalLink,
Clock,
- Coins
+ Coins,
+ Link2,
+ CreditCard
} from "lucide-react";
import {
testTriggerPlaidTransactionImport,
@@ -19,7 +21,10 @@ import {
testTriggerPlaidBalanceSync,
testTriggerGoCardlessBalanceSync,
testTriggerBothProvidersTransactionImport,
- testTriggerBothProvidersBalanceSync
+ testTriggerBothProvidersBalanceSync,
+ testTriggerPlaidConnection,
+ testTriggerGoCardlessConnection,
+ testTriggerBothProvidersConnection
} from "@/actions/trigger-test-actions";
import { toast } from "sonner";
import {
@@ -233,10 +238,70 @@ export function TriggerTestButton({
+ {/* Bank Connection Tests */}
+
+ Bank Connections (Mock Data)
+
+
+ handleOperation(
+ () => testTriggerPlaidConnection(),
+ "Plaid Connection Test"
+ )}
+ disabled={isLoading}
+ className="flex items-center justify-between"
+ >
+
+
+ Plaid Connection
+
+ {isLoading && activeOperation === "Plaid Connection Test" && (
+
+ )}
+
+
+ handleOperation(
+ () => testTriggerGoCardlessConnection(),
+ "GoCardless Connection Test"
+ )}
+ disabled={isLoading}
+ className="flex items-center justify-between"
+ >
+
+
+ GoCardless Connection
+
+ {isLoading && activeOperation === "GoCardless Connection Test" && (
+
+ )}
+
+
+ handleOperation(
+ () => testTriggerBothProvidersConnection(),
+ "Both Providers Connection Test"
+ )}
+ disabled={isLoading}
+ className="flex items-center justify-between font-medium"
+ >
+
+
+ Both Connections
+
+ {isLoading && activeOperation === "Both Providers Connection Test" && (
+
+ )}
+
+
+
+
⚡ Tasks run async in background
📊 Monitor in Trigger.dev Dashboard
+
+ 🔗 Connection tests use mock data
From 038590e9cf0cef1759368051c8f8b9a80fd93d22 Mon Sep 17 00:00:00 2001
From: meglerhagen
Date: Wed, 16 Jul 2025 20:53:58 +0200
Subject: [PATCH 5/5] wip
---
.content-collections/generated/index.js | 2 +-
.../financial/trigger-test-button.tsx | 197 ++++++++++--------
2 files changed, 109 insertions(+), 90 deletions(-)
diff --git a/.content-collections/generated/index.js b/.content-collections/generated/index.js
index 88917aa8..68abad15 100644
--- a/.content-collections/generated/index.js
+++ b/.content-collections/generated/index.js
@@ -1,4 +1,4 @@
-// generated by content-collections at Thu Jul 03 2025 11:28:51 GMT+0200 (Central European Summer Time)
+// generated by content-collections at Wed Jul 16 2025 20:44:22 GMT+0200 (Central European Summer Time)
import allBlogs from "./allBlogs.js";
import allHelps from "./allHelps.js";
diff --git a/src/components/financial/trigger-test-button.tsx b/src/components/financial/trigger-test-button.tsx
index 034ff03b..b7e2d948 100644
--- a/src/components/financial/trigger-test-button.tsx
+++ b/src/components/financial/trigger-test-button.tsx
@@ -2,20 +2,20 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
-import {
- Zap,
- Play,
- Calendar,
- Sync,
- AlertCircle,
- CheckCircle,
+import {
+ Zap,
+ Play,
+ Calendar,
+ AlertCircle,
+ CheckCircle,
ExternalLink,
Clock,
Coins,
Link2,
- CreditCard
+ CreditCard,
+ RefreshCcw,
} from "lucide-react";
-import {
+import {
testTriggerPlaidTransactionImport,
testTriggerGoCardlessTransactionImport,
testTriggerPlaidBalanceSync,
@@ -24,7 +24,7 @@ import {
testTriggerBothProvidersBalanceSync,
testTriggerPlaidConnection,
testTriggerGoCardlessConnection,
- testTriggerBothProvidersConnection
+ testTriggerBothProvidersConnection,
} from "@/actions/trigger-test-actions";
import { toast } from "sonner";
import {
@@ -42,10 +42,10 @@ interface TriggerTestButtonProps {
size?: "default" | "sm" | "lg";
}
-export function TriggerTestButton({
- onSuccess,
- variant = "outline",
- size = "default"
+export function TriggerTestButton({
+ onSuccess,
+ variant = "outline",
+ size = "default",
}: TriggerTestButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const [activeOperation, setActiveOperation] = useState(null);
@@ -57,18 +57,18 @@ export function TriggerTestButton({
try {
setIsLoading(true);
setActiveOperation(operationName);
-
+
const result = await operation();
-
+
if (result.success) {
toast.success(result.message, {
icon: ,
description: result.url ? (
-
+
{/* Transaction Import Tests */}
Transaction Import
-
- handleOperation(
- () => testTriggerPlaidTransactionImport(7),
- "Plaid Import (7d)"
- )}
+
+
+ handleOperation(
+ () => testTriggerPlaidTransactionImport(7),
+ "Plaid Import (7d)"
+ )
+ }
disabled={isLoading}
className="flex items-center justify-between"
>
@@ -143,12 +145,14 @@ export function TriggerTestButton({
)}
-
- handleOperation(
- () => testTriggerGoCardlessTransactionImport(7),
- "GoCardless Import (7d)"
- )}
+
+
+ handleOperation(
+ () => testTriggerGoCardlessTransactionImport(7),
+ "GoCardless Import (7d)"
+ )
+ }
disabled={isLoading}
className="flex items-center justify-between"
>
@@ -160,12 +164,14 @@ export function TriggerTestButton({
)}
-
- handleOperation(
- () => testTriggerBothProvidersTransactionImport(30),
- "Both Providers Import (30d)"
- )}
+
+
+ handleOperation(
+ () => testTriggerBothProvidersTransactionImport(30),
+ "Both Providers Import (30d)"
+ )
+ }
disabled={isLoading}
className="flex items-center justify-between font-medium"
>
@@ -177,53 +183,59 @@ export function TriggerTestButton({
)}
-
+
-
+
{/* Balance Sync Tests */}
Balance Sync
-
- handleOperation(
- () => testTriggerPlaidBalanceSync(),
- "Plaid Balance Sync"
- )}
+
+
+ handleOperation(
+ () => testTriggerPlaidBalanceSync(),
+ "Plaid Balance Sync"
+ )
+ }
disabled={isLoading}
className="flex items-center justify-between"
>
-
+
Plaid Balances
{isLoading && activeOperation === "Plaid Balance Sync" && (
)}
-
- handleOperation(
- () => testTriggerGoCardlessBalanceSync(),
- "GoCardless Balance Sync"
- )}
+
+
+ handleOperation(
+ () => testTriggerGoCardlessBalanceSync(),
+ "GoCardless Balance Sync"
+ )
+ }
disabled={isLoading}
className="flex items-center justify-between"
>
-
+
GoCardless Balances
{isLoading && activeOperation === "GoCardless Balance Sync" && (
)}
-
- handleOperation(
- () => testTriggerBothProvidersBalanceSync(),
- "Both Providers Balance Sync"
- )}
+
+
+ handleOperation(
+ () => testTriggerBothProvidersBalanceSync(),
+ "Both Providers Balance Sync"
+ )
+ }
disabled={isLoading}
className="flex items-center justify-between font-medium"
>
@@ -235,19 +247,21 @@ export function TriggerTestButton({
)}
-
+
-
+
{/* Bank Connection Tests */}
Bank Connections (Mock Data)
-
- handleOperation(
- () => testTriggerPlaidConnection(),
- "Plaid Connection Test"
- )}
+
+
+ handleOperation(
+ () => testTriggerPlaidConnection(),
+ "Plaid Connection Test"
+ )
+ }
disabled={isLoading}
className="flex items-center justify-between"
>
@@ -259,12 +273,14 @@ export function TriggerTestButton({
)}
-
- handleOperation(
- () => testTriggerGoCardlessConnection(),
- "GoCardless Connection Test"
- )}
+
+
+ handleOperation(
+ () => testTriggerGoCardlessConnection(),
+ "GoCardless Connection Test"
+ )
+ }
disabled={isLoading}
className="flex items-center justify-between"
>
@@ -276,12 +292,14 @@ export function TriggerTestButton({
)}
-
- handleOperation(
- () => testTriggerBothProvidersConnection(),
- "Both Providers Connection Test"
- )}
+
+
+ handleOperation(
+ () => testTriggerBothProvidersConnection(),
+ "Both Providers Connection Test"
+ )
+ }
disabled={isLoading}
className="flex items-center justify-between font-medium"
>
@@ -289,13 +307,14 @@ export function TriggerTestButton({
Both Connections
- {isLoading && activeOperation === "Both Providers Connection Test" && (
-
- )}
+ {isLoading &&
+ activeOperation === "Both Providers Connection Test" && (
+
+ )}
-
+
-
+
⚡ Tasks run async in background
@@ -306,4 +325,4 @@ export function TriggerTestButton({
);
-}
\ No newline at end of file
+}