Technical walkthrough of integrating SimpleFIN for automatic transaction imports while maintaining data privacy.

One of Moneypile's most requested features was automatic bank synchronization. Today, we're pulling back the curtain on how we integrated SimpleFIN to provide secure, privacy-focused transaction imports.

Why SimpleFIN?

When evaluating bank sync providers, we had strict requirements:

  • End-to-end encryption of financial data
  • No storage of credentials on third-party servers
  • Support for a wide range of financial institutions
  • Developer-friendly API

SimpleFIN checked all these boxes. Unlike other providers that store and analyze your financial data, SimpleFIN acts as a secure bridge between your bank and Moneypile.

Architecture Overview

Our integration follows a privacy-first architecture:


graph LR
    A[User's Bank] -->|Encrypted| B[SimpleFIN Bridge]
    B -->|Encrypted| C[Moneypile Server]
    C -->|Local Storage| D[SQLite Database]
    
    style A fill:#f9f,stroke:#333,stroke-width:2px
    style D fill:#9f9,stroke:#333,stroke-width:2px

The Integration Flow

Here's how the SimpleFIN integration works in practice:

// 1. User initiates connection
async function connectSimpleFIN(userId: string) {
  // Generate unique setup token
  const setupToken = await generateSetupToken();
  
  // Create SimpleFIN setup URL
  const setupUrl = `https://bridge.simplefin.org/simplefin/setup/${setupToken}`;
  
  // Store encrypted token
  await db.insert(simplefinConfigs).values({
    userId,
    setupToken: encrypt(setupToken),
    status: 'pending'
  });
  
  return setupUrl;
}

Security Implementation

Security was our top priority. We implemented multiple layers of protection:

1. Token Encryption

import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';

class SimpleFINEncryption {
  private algorithm = 'aes-256-gcm';
  private key: Buffer;
  
  constructor() {
    // Key derived from environment variable
    this.key = Buffer.from(process.env.SIMPLEFIN_ENCRYPTION_KEY!, 'hex');
  }
  
  encrypt(text: string): EncryptedData {
    const iv = randomBytes(16);
    const cipher = createCipheriv(this.algorithm, this.key, iv);
    
    let encrypted = cipher.update(text, 'utf8', 'hex');
    encrypted += cipher.final('hex');
    
    const authTag = cipher.getAuthTag();
    
    return {
      encrypted,
      iv: iv.toString('hex'),
      authTag: authTag.toString('hex')
    };
  }
  
  decrypt(data: EncryptedData): string {
    const decipher = createDecipheriv(
      this.algorithm,
      this.key,
      Buffer.from(data.iv, 'hex')
    );
    
    decipher.setAuthTag(Buffer.from(data.authTag, 'hex'));
    
    let decrypted = decipher.update(data.encrypted, 'hex', 'utf8');
    decrypted += decipher.final('utf8');
    
    return decrypted;
  }
}

2. Secure API Communication

async function fetchTransactions(accessUrl: string) {
  try {
    // Parse and validate URL
    const url = new URL(accessUrl);
    if (url.protocol !== 'https:') {
      throw new Error('SimpleFIN URL must use HTTPS');
    }
    
    // Extract credentials from URL
    const [username, password] = Buffer.from(
      url.username + ':' + url.password
    ).toString('base64');
    
    // Make authenticated request
    const response = await fetch(`${url.origin}/accounts`, {
      headers: {
        'Authorization': `Basic ${credentials}`,
        'User-Agent': 'Moneypile/1.0'
      }
    });
    
    if (!response.ok) {
      throw new Error(`SimpleFIN API error: ${response.status}`);
    }
    
    return await response.json();
  } catch (error) {
    // Never log sensitive data
    logger.error('SimpleFIN fetch failed', { 
      error: error.message 
    });
    throw error;
  }
}

Data Transformation

SimpleFIN provides raw transaction data that we transform to match Moneypile's schema:

interface SimpleFINTransaction {
  id: string;
  posted: string;
  amount: string;
  description: string;
  payee?: string;
  pending: boolean;
}

function transformTransaction(
  sfTx: SimpleFINTransaction,
  accountId: string
): MoneypileTransaction {
  return {
    id: generateTransactionId(sfTx.id, accountId),
    account_id: accountId,
    date: new Date(sfTx.posted).toISOString(),
    amount: Math.round(parseFloat(sfTx.amount) * 100), // Store as cents
    payee_name: sfTx.payee || sfTx.description,
    imported_payee: sfTx.description,
    notes: sfTx.pending ? 'Pending transaction' : null,
    cleared: !sfTx.pending,
    imported_id: sfTx.id,
    transfer_id: null,
    sort_order: Date.now()
  };
}

Handling Edge Cases

Real-world integration revealed several edge cases we had to handle:

1. Duplicate Detection

async function importTransactions(transactions: MoneypileTransaction[]) {
  const existing = await db.select()
    .from(transactionsTable)
    .where(
      inArray(
        transactionsTable.imported_id,
        transactions.map(t => t.imported_id)
      )
    );
  
  const existingIds = new Set(existing.map(t => t.imported_id));
  const newTransactions = transactions.filter(
    t => !existingIds.has(t.imported_id)
  );
  
  if (newTransactions.length > 0) {
    await db.insert(transactionsTable).values(newTransactions);
  }
  
  return {
    imported: newTransactions.length,
    duplicates: transactions.length - newTransactions.length
  };
}

2. Balance Reconciliation

async function reconcileBalance(
  accountId: string,
  simplefinBalance: number
) {
  const dbBalance = await calculateBalance(accountId);
  const difference = simplefinBalance - dbBalance;
  
  if (Math.abs(difference) > 0.01) {
    // Create reconciliation transaction
    await createReconciliationTransaction(accountId, difference);
    
    // Notify user
    await notifyBalanceDiscrepancy(accountId, difference);
  }
}

Performance Optimization

With users having thousands of transactions, performance was crucial:

  • Batch Processing: Import transactions in chunks of 100
  • Indexed Queries: Added indexes on imported_id and date
  • Background Sync: Sync runs in a worker thread
// Background sync worker
class SyncWorker {
  async syncAllAccounts() {
    const configs = await getActiveSimplefinConfigs();
    
    for (const config of configs) {
      await this.syncAccount(config);
      
      // Rate limiting to be respectful
      await sleep(1000);
    }
  }
  
  private async syncAccount(config: SimplefinConfig) {
    const queue = new PQueue({ concurrency: 1 });
    
    try {
      const accounts = await fetchAccounts(config);
      
      for (const account of accounts) {
        queue.add(() => this.syncTransactions(account));
      }
      
      await queue.onIdle();
    } catch (error) {
      await this.handleSyncError(config, error);
    }
  }
}

Privacy Considerations

Every design decision prioritized user privacy:

  • SimpleFIN tokens are encrypted at rest
  • No transaction data is ever sent to Moneypile servers
  • Users can revoke access at any time
  • All sync happens locally on the user's server

User Experience

We made the setup process as simple as possible:

  1. Click "Connect Bank Account" in settings
  2. Authenticate with your bank through SimpleFIN
  3. Select accounts to sync
  4. Transactions appear automatically

Behind this simplicity lies the complex integration we've described.

Lessons Learned

Building this integration taught us valuable lessons:

"The hardest part wasn't the technical implementationit was designing a system that users could trust with their financial data."

  • Clear communication about data handling builds trust
  • Edge cases in financial data are numerous and important
  • Performance matters when dealing with years of transactions
  • Security can't be an afterthoughtit must be designed in

What's Next

We're continually improving our SimpleFIN integration:

  • Real-time webhooks: Instant transaction notifications
  • Smart categorization: ML-powered transaction categorization
  • Multi-currency support: Handle international accounts
  • Advanced reconciliation: Automated balance matching

Try It Yourself

Ready to connect your bank accounts? The SimpleFIN integration is available in all Moneypile installations. Simply navigate to Settings � Bank Connections and follow the setup wizard.

For developers interested in the implementation details, check out our SimpleFIN integration code on GitHub.

We believe that automatic bank sync shouldn't require sacrificing privacy. With SimpleFIN and Moneypile, you can have both.