467 lines
14 KiB
TypeScript
467 lines
14 KiB
TypeScript
'use strict';
|
|
|
|
import type { Db } from '../types/db';
|
|
|
|
const { assertEncryptionReady, encryptSecret, decryptSecret } = require('./encryptionService.cts');
|
|
const {
|
|
claimSetupToken,
|
|
fetchAccountsAndTransactions,
|
|
normalizeAccount,
|
|
normalizeTransaction,
|
|
sanitizeErrorMessage,
|
|
} = require('./simplefinService.cts');
|
|
const {
|
|
getBankSyncConfig,
|
|
SYNC_DAYS_EFFECTIVE,
|
|
SYNC_DAYS_DEFAULT,
|
|
} = require('./bankSyncConfigService.cts');
|
|
const { decorateDataSource } = require('./transactionService.cts');
|
|
const { applyMerchantRules } = require('./billMerchantRuleService.cts');
|
|
const { applySpendingCategoryRules } = require('./spendingService.cts');
|
|
const { applyMerchantStoreMatches } = require('./merchantStoreMatchService.cts');
|
|
const { autoMatchForUser } = require('./matchSuggestionService.cts');
|
|
const { getUserSettings } = require('./userSettings.cts');
|
|
|
|
type Row = Record<string, any>;
|
|
|
|
function sinceEpochDays(days: number): number {
|
|
return Math.floor((Date.now() - days * 86400 * 1000) / 1000);
|
|
}
|
|
|
|
function safeErrorMessage(err: any): string {
|
|
return sanitizeErrorMessage(err?.message || String(err || 'Sync failed'));
|
|
}
|
|
|
|
// Upsert a single financial account, return the local row.
|
|
function upsertAccount(db: Db, accountRow: Row): Row {
|
|
const existing = db
|
|
.prepare(
|
|
`
|
|
SELECT id, monitored 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;
|
|
}
|
|
|
|
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 { id: result.lastInsertRowid, monitored: 1 };
|
|
}
|
|
|
|
// Insert a transaction, or update one we previously stored as PENDING.
|
|
// Returns: 'inserted' | 'posted' (pending→settled) | 'updated' (pending refreshed) | 'skipped'.
|
|
function upsertTransaction(db: Db, txRow: Row): string {
|
|
const existing = db
|
|
.prepare(
|
|
`
|
|
SELECT id, pending, match_status FROM transactions
|
|
WHERE user_id = ? AND provider_transaction_id = ?
|
|
`,
|
|
)
|
|
.get(txRow.user_id, txRow.provider_transaction_id);
|
|
|
|
if (!existing) {
|
|
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, pending, 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.pending,
|
|
txRow.raw_data,
|
|
);
|
|
return 'inserted';
|
|
} catch (err: any) {
|
|
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE' || (err.message || '').includes('UNIQUE')) {
|
|
return 'skipped';
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Only refresh rows still pending in our DB and not yet acted on by the user.
|
|
if (existing.pending === 1 && existing.match_status === 'unmatched') {
|
|
db.prepare(
|
|
`
|
|
UPDATE transactions
|
|
SET posted_date = ?, transacted_at = ?, amount = ?, currency = ?,
|
|
description = ?, payee = ?, memo = ?, pending = ?, raw_data = ?,
|
|
updated_at = datetime('now')
|
|
WHERE id = ?
|
|
`,
|
|
).run(
|
|
txRow.posted_date,
|
|
txRow.transacted_at,
|
|
txRow.amount,
|
|
txRow.currency,
|
|
txRow.description,
|
|
txRow.payee,
|
|
txRow.memo,
|
|
txRow.pending,
|
|
txRow.raw_data,
|
|
existing.id,
|
|
);
|
|
return txRow.pending === 0 ? 'posted' : 'updated';
|
|
}
|
|
|
|
return 'skipped';
|
|
}
|
|
|
|
async function runSync(
|
|
db: Db,
|
|
userId: number,
|
|
dataSource: Row,
|
|
{ days, debug = false }: { days?: number; debug?: boolean } = {},
|
|
): Promise<any> {
|
|
const accessUrl = decryptSecret(dataSource.encrypted_secret);
|
|
const isFirstSync = !dataSource.last_sync_at;
|
|
const config = getBankSyncConfig();
|
|
const syncDays =
|
|
days ?? (isFirstSync ? SYNC_DAYS_EFFECTIVE : config.sync_days || SYNC_DAYS_DEFAULT);
|
|
const since = sinceEpochDays(syncDays);
|
|
|
|
if (debug)
|
|
console.log(
|
|
`[bankSync:debug] Source #${dataSource.id} user ${userId}: fetching ${syncDays} days from SimpleFIN (since epoch ${since})`,
|
|
);
|
|
|
|
const raw = await fetchAccountsAndTransactions(accessUrl, since);
|
|
const accounts = Array.isArray(raw.accounts) ? raw.accounts : [];
|
|
|
|
if (debug)
|
|
console.log(
|
|
`[bankSync:debug] Source #${dataSource.id}: SimpleFIN returned ${accounts.length} account(s)`,
|
|
);
|
|
|
|
if (raw._errlistSummary) {
|
|
console.warn(`[bankSync] errlist for source ${dataSource.id}: ${raw._errlistSummary}`);
|
|
}
|
|
|
|
let accountsUpserted = 0;
|
|
let transactionsNew = 0;
|
|
let transactionsSkip = 0;
|
|
let transactionsPosted = 0; // pending → settled this cycle
|
|
let pendingCleared = 0; // stale pending rows pruned (re-posted under new id / dropped)
|
|
|
|
const pruneOrphanPending = db.prepare(`
|
|
DELETE FROM transactions
|
|
WHERE account_id = ? AND user_id = ? AND pending = 1 AND match_status = 'unmatched'
|
|
AND provider_transaction_id NOT IN (SELECT value FROM json_each(?))
|
|
`);
|
|
|
|
const partialError = raw._errlistSummary
|
|
? sanitizeErrorMessage(`Partial sync — some connections failed: ${raw._errlistSummary}`)
|
|
: null;
|
|
|
|
const updateSource = db.prepare(`
|
|
UPDATE data_sources
|
|
SET last_sync_at = datetime('now'), last_error = ?, status = 'active', updated_at = datetime('now')
|
|
WHERE id = ? AND user_id = ?
|
|
`);
|
|
|
|
const ingest = db.transaction(() => {
|
|
for (const rawAccount of accounts) {
|
|
const accountRow = normalizeAccount(rawAccount, dataSource.id, userId);
|
|
const localAccount = upsertAccount(db, accountRow);
|
|
accountsUpserted += 1;
|
|
|
|
const txList = rawAccount.transactions || [];
|
|
if (debug)
|
|
console.log(
|
|
`[bankSync:debug] Account "${rawAccount.name}" (monitored=${localAccount.monitored}): ${txList.length} transaction(s)`,
|
|
);
|
|
|
|
if (localAccount.monitored === 0) continue;
|
|
|
|
const seenTxIds: any[] = [];
|
|
for (const rawTx of txList) {
|
|
const txRow = normalizeTransaction(
|
|
rawTx,
|
|
localAccount.id,
|
|
dataSource.id,
|
|
userId,
|
|
rawAccount.id,
|
|
rawAccount.currency,
|
|
);
|
|
seenTxIds.push(txRow.provider_transaction_id);
|
|
const outcome = upsertTransaction(db, txRow);
|
|
if (outcome === 'inserted') {
|
|
transactionsNew += 1;
|
|
if (debug)
|
|
console.log(
|
|
`[bankSync:debug] tx ${txRow.provider_transaction_id}: inserted${txRow.pending ? ' (pending)' : ''} (${rawTx.description || rawTx.payee || '—'}, ${txRow.amount}¢)`,
|
|
);
|
|
} else if (outcome === 'posted') {
|
|
transactionsPosted += 1;
|
|
if (debug)
|
|
console.log(
|
|
`[bankSync:debug] tx ${txRow.provider_transaction_id}: pending → posted`,
|
|
);
|
|
} else if (outcome === 'updated') {
|
|
if (debug)
|
|
console.log(
|
|
`[bankSync:debug] tx ${txRow.provider_transaction_id}: pending amount refreshed`,
|
|
);
|
|
} else {
|
|
transactionsSkip += 1;
|
|
if (debug)
|
|
console.log(
|
|
`[bankSync:debug] tx ${txRow.provider_transaction_id}: duplicate — skipped`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const orphans = pruneOrphanPending.run(localAccount.id, userId, JSON.stringify(seenTxIds));
|
|
if (orphans.changes > 0) {
|
|
pendingCleared += orphans.changes;
|
|
if (debug)
|
|
console.log(
|
|
`[bankSync:debug] Account "${rawAccount.name}": pruned ${orphans.changes} stale pending row(s)`,
|
|
);
|
|
}
|
|
}
|
|
|
|
updateSource.run(partialError, dataSource.id, userId);
|
|
});
|
|
ingest();
|
|
|
|
if (debug)
|
|
console.log(`[bankSync:debug] Source #${dataSource.id}: applying merchant rules + auto-match`);
|
|
|
|
// Apply stored merchant→bill rules, then spending category rules, then score-based auto-match
|
|
const {
|
|
matched: autoMatched,
|
|
matched_bills: matchedBills,
|
|
late_attributions: lateAttributions,
|
|
} = applyMerchantRules(db, userId);
|
|
try {
|
|
applySpendingCategoryRules(db, userId);
|
|
} catch {
|
|
/* non-blocking */
|
|
}
|
|
try {
|
|
if (getUserSettings(userId).bank_auto_categorize_merchants !== 'false') {
|
|
applyMerchantStoreMatches(db, userId);
|
|
}
|
|
} catch {
|
|
/* non-blocking */
|
|
}
|
|
try {
|
|
autoMatchForUser(userId);
|
|
} catch {
|
|
/* non-blocking */
|
|
}
|
|
|
|
if (debug)
|
|
console.log(
|
|
`[bankSync:debug] Source #${dataSource.id}: auto-matched ${autoMatched} transaction(s)`,
|
|
);
|
|
|
|
return {
|
|
accountsUpserted,
|
|
transactionsNew,
|
|
transactionsSkip,
|
|
transactionsPosted,
|
|
pendingCleared,
|
|
autoMatched,
|
|
matched_bills: matchedBills || [],
|
|
late_attributions: lateAttributions || [],
|
|
errlist: raw._errlistSummary || null,
|
|
};
|
|
}
|
|
|
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
|
|
async function connectSimplefin(db: Db, userId: number, setupToken: any): Promise<any> {
|
|
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: any = { 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: Db,
|
|
userId: number,
|
|
dataSourceId: any,
|
|
{ debug }: { debug?: boolean } = {},
|
|
): Promise<any> {
|
|
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');
|
|
|
|
const useDebug = debug ?? getBankSyncConfig().debug_logging;
|
|
|
|
let syncResult;
|
|
try {
|
|
syncResult = await runSync(db, userId, dataSource, { debug: useDebug });
|
|
} 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 };
|
|
}
|
|
|
|
async function backfillDataSource(db: Db, userId: number, dataSourceId: any): Promise<any> {
|
|
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, { days: SYNC_DAYS_EFFECTIVE });
|
|
} 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);
|
|
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: Db, userId: number, dataSourceId: any): void {
|
|
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, backfillDataSource, disconnectDataSource };
|