195 lines
7.3 KiB
JavaScript
195 lines
7.3 KiB
JavaScript
'use strict';
|
|
|
|
const { assertEncryptionReady, encryptSecret, decryptSecret } = require('./encryptionService');
|
|
const {
|
|
claimSetupToken,
|
|
fetchAccountsAndTransactions,
|
|
normalizeAccount,
|
|
normalizeTransaction,
|
|
sanitizeErrorMessage,
|
|
} = require('./simplefinService');
|
|
const { getBankSyncConfig } = require('./bankSyncConfigService');
|
|
const { decorateDataSource } = require('./transactionService');
|
|
|
|
function sinceEpoch() {
|
|
const { sync_days } = getBankSyncConfig();
|
|
return Math.floor((Date.now() - sync_days * 86400 * 1000) / 1000);
|
|
}
|
|
|
|
function safeErrorMessage(err) {
|
|
return sanitizeErrorMessage(err?.message || String(err || 'Sync failed'));
|
|
}
|
|
|
|
// Upsert a single financial account, return the local row.
|
|
function upsertAccount(db, accountRow) {
|
|
const existing = db.prepare(`
|
|
SELECT id FROM financial_accounts
|
|
WHERE data_source_id = ? AND provider_account_id = ? AND user_id = ?
|
|
`).get(accountRow.data_source_id, accountRow.provider_account_id, accountRow.user_id);
|
|
|
|
if (existing) {
|
|
db.prepare(`
|
|
UPDATE financial_accounts
|
|
SET name = ?, org_name = ?, currency = ?, balance = ?, available_balance = ?,
|
|
raw_data = ?, updated_at = datetime('now')
|
|
WHERE id = ?
|
|
`).run(
|
|
accountRow.name, accountRow.org_name, accountRow.currency,
|
|
accountRow.balance, accountRow.available_balance, accountRow.raw_data,
|
|
existing.id,
|
|
);
|
|
return existing.id;
|
|
}
|
|
|
|
const result = db.prepare(`
|
|
INSERT INTO financial_accounts
|
|
(user_id, data_source_id, provider_account_id, name, org_name, account_type,
|
|
currency, balance, available_balance, raw_data)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
accountRow.user_id, accountRow.data_source_id, accountRow.provider_account_id,
|
|
accountRow.name, accountRow.org_name, accountRow.account_type,
|
|
accountRow.currency, accountRow.balance, accountRow.available_balance, accountRow.raw_data,
|
|
);
|
|
return result.lastInsertRowid;
|
|
}
|
|
|
|
// Insert a transaction, ignoring duplicates (unique index on data_source_id + provider_transaction_id).
|
|
function insertTransactionIfNew(db, txRow) {
|
|
try {
|
|
db.prepare(`
|
|
INSERT INTO transactions
|
|
(user_id, data_source_id, account_id, provider_transaction_id,
|
|
source_type, posted_date, transacted_at, amount, currency,
|
|
description, payee, memo, match_status, ignored, raw_data)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
txRow.user_id, txRow.data_source_id, txRow.account_id, txRow.provider_transaction_id,
|
|
txRow.source_type, txRow.posted_date, txRow.transacted_at, txRow.amount, txRow.currency,
|
|
txRow.description, txRow.payee, txRow.memo, txRow.match_status, txRow.ignored, txRow.raw_data,
|
|
);
|
|
return 'inserted';
|
|
} catch (err) {
|
|
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE' || (err.message || '').includes('UNIQUE')) {
|
|
return 'skipped';
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function runSync(db, userId, dataSource) {
|
|
const accessUrl = decryptSecret(dataSource.encrypted_secret);
|
|
const since = sinceEpoch();
|
|
|
|
const raw = await fetchAccountsAndTransactions(accessUrl, since);
|
|
const accounts = Array.isArray(raw.accounts) ? raw.accounts : [];
|
|
|
|
if (raw._errlistSummary) {
|
|
console.warn(`[bankSync] errlist for source ${dataSource.id}: ${raw._errlistSummary}`);
|
|
}
|
|
|
|
let accountsUpserted = 0;
|
|
let transactionsNew = 0;
|
|
let transactionsSkip = 0;
|
|
|
|
for (const rawAccount of accounts) {
|
|
const accountRow = normalizeAccount(rawAccount, dataSource.id, userId);
|
|
const localAccId = upsertAccount(db, accountRow);
|
|
accountsUpserted += 1;
|
|
|
|
for (const rawTx of (rawAccount.transactions || [])) {
|
|
const txRow = normalizeTransaction(
|
|
rawTx, localAccId, dataSource.id, userId, dataSource.id, rawAccount.id,
|
|
);
|
|
const outcome = insertTransactionIfNew(db, txRow);
|
|
if (outcome === 'inserted') transactionsNew += 1;
|
|
else transactionsSkip += 1;
|
|
}
|
|
}
|
|
|
|
// Store any errlist warnings alongside a successful sync so users can see them
|
|
const partialError = raw._errlistSummary
|
|
? sanitizeErrorMessage(`Partial sync — some connections failed: ${raw._errlistSummary}`)
|
|
: null;
|
|
|
|
db.prepare(`
|
|
UPDATE data_sources
|
|
SET last_sync_at = datetime('now'), last_error = ?, status = 'active', updated_at = datetime('now')
|
|
WHERE id = ? AND user_id = ?
|
|
`).run(partialError, dataSource.id, userId);
|
|
|
|
return { accountsUpserted, transactionsNew, transactionsSkip, errlist: raw._errlistSummary || null };
|
|
}
|
|
|
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
|
|
async function connectSimplefin(db, userId, setupToken) {
|
|
assertEncryptionReady();
|
|
|
|
const accessUrl = await claimSetupToken(setupToken);
|
|
const encrypted = encryptSecret(accessUrl);
|
|
|
|
const result = db.prepare(`
|
|
INSERT INTO data_sources (user_id, type, provider, name, status, encrypted_secret)
|
|
VALUES (?, 'provider_sync', 'simplefin', 'SimpleFIN', 'active', ?)
|
|
`).run(userId, encrypted);
|
|
|
|
const dataSourceId = result.lastInsertRowid;
|
|
const dataSource = db.prepare('SELECT * FROM data_sources WHERE id = ? AND user_id = ?').get(dataSourceId, userId);
|
|
|
|
let syncResult = { accountsUpserted: 0, transactionsNew: 0, transactionsSkip: 0 };
|
|
try {
|
|
syncResult = await runSync(db, userId, dataSource);
|
|
} catch (err) {
|
|
const msg = safeErrorMessage(err);
|
|
db.prepare(`
|
|
UPDATE data_sources SET last_error = ?, status = 'error', updated_at = datetime('now')
|
|
WHERE id = ?
|
|
`).run(msg, dataSourceId);
|
|
}
|
|
|
|
const fresh = db.prepare('SELECT * FROM data_sources WHERE id = ? AND user_id = ?').get(dataSourceId, userId);
|
|
return { dataSource: decorateDataSource(fresh), ...syncResult };
|
|
}
|
|
|
|
async function syncDataSource(db, userId, dataSourceId) {
|
|
assertEncryptionReady();
|
|
|
|
const dataSource = db.prepare(`
|
|
SELECT * FROM data_sources
|
|
WHERE id = ? AND user_id = ? AND type = 'provider_sync' AND provider = 'simplefin'
|
|
`).get(dataSourceId, userId);
|
|
|
|
if (!dataSource) throw Object.assign(new Error('SimpleFIN connection not found'), { status: 404 });
|
|
if (!dataSource.encrypted_secret) throw new Error('No stored credentials for this connection');
|
|
|
|
let syncResult;
|
|
try {
|
|
syncResult = await runSync(db, userId, dataSource);
|
|
} catch (err) {
|
|
const msg = safeErrorMessage(err);
|
|
db.prepare(`
|
|
UPDATE data_sources SET last_error = ?, status = 'error', updated_at = datetime('now')
|
|
WHERE id = ?
|
|
`).run(msg, dataSourceId);
|
|
// Re-throw so the route can surface a meaningful error
|
|
throw err;
|
|
}
|
|
|
|
const fresh = db.prepare('SELECT * FROM data_sources WHERE id = ? AND user_id = ?').get(dataSourceId, userId);
|
|
return { dataSource: decorateDataSource(fresh), ...syncResult };
|
|
}
|
|
|
|
function disconnectDataSource(db, userId, dataSourceId) {
|
|
const row = db.prepare(`
|
|
SELECT id FROM data_sources WHERE id = ? AND user_id = ? AND provider = 'simplefin'
|
|
`).get(dataSourceId, userId);
|
|
|
|
if (!row) throw Object.assign(new Error('SimpleFIN connection not found'), { status: 404 });
|
|
|
|
// Financial accounts cascade-delete. Transactions get data_source_id = NULL (SET NULL FK).
|
|
db.prepare('DELETE FROM data_sources WHERE id = ? AND user_id = ?').run(dataSourceId, userId);
|
|
}
|
|
|
|
module.exports = { connectSimplefin, syncDataSource, disconnectDataSource };
|