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 +}