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.
When evaluating bank sync providers, we had strict requirements:
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.
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
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 was our top priority. We implemented multiple layers of protection:
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;
}
}
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;
}
}
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()
};
}
Real-world integration revealed several edge cases we had to handle:
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
};
}
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);
}
}
With users having thousands of transactions, performance was crucial:
// 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);
}
}
}
Every design decision prioritized user privacy:
We made the setup process as simple as possible:
Behind this simplicity lies the complex integration we've described.
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."
We're continually improving our SimpleFIN integration:
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.