// @ts-strict-ignore import * as connection from '../../platform/server/connection'; import { TransactionEntity } from '../../types/models'; import * as db from '../db'; import { incrFetch, whereIn } from '../db/util'; import { batchMessages } from '../sync'; import * as rules from './transaction-rules'; import * as transfer from './transfer'; async function idsWithChildren(ids: string[]) { const whereIds = whereIn(ids, 'parent_id'); const rows = await db.all( `SELECT id FROM v_transactions_internal WHERE ${whereIds}`, ); const set = new Set(ids); for (const row of rows) { set.add(row.id); } return [...set]; } async function getTransactionsByIds( ids: string[], ): Promise<TransactionEntity[]> { // TODO: convert to whereIn // // or better yet, use ActualQL return incrFetch( (query, params) => db.selectWithSchema('transactions', query, params), ids, // eslint-disable-next-line rulesdir/typography id => `id = '${id}'`, where => `SELECT * FROM v_transactions_internal WHERE ${where}`, ); } export async function batchUpdateTransactions({ added, deleted, updated, learnCategories = false, detectOrphanPayees = true, runTransfers = true, }: { added?: Array<{ id: string; payee: unknown; category: unknown }>; deleted?: Array<{ id: string; payee: unknown }>; updated?: Array<{ id: string; payee?: unknown; account?: unknown; category?: unknown; }>; learnCategories?: boolean; detectOrphanPayees?: boolean; runTransfers?: boolean; }) { // Track the ids of each type of transaction change (see below for why) let addedIds = []; const updatedIds = updated ? updated.map(u => u.id) : []; const deletedIds = deleted ? await idsWithChildren(deleted.map(d => d.id)) : []; const oldPayees = new Set(); const accounts = await db.all('SELECT * FROM accounts WHERE tombstone = 0'); // We need to get all the payees of updated transactions _before_ // making changes if (updated) { const descUpdatedIds = updated .filter(update => update.payee) .map(update => update.id); const transactions = await getTransactionsByIds(descUpdatedIds); for (let i = 0; i < transactions.length; i++) { oldPayees.add(transactions[i].payee); } } // Apply all the updates. We can batch this now! This is important // and makes bulk updates much faster await batchMessages(async () => { if (added) { addedIds = await Promise.all( added.map(async t => db.insertTransaction(t)), ); } if (deleted) { await Promise.all( // It's important to use `deletedIds` and not `deleted` here // because we've expanded it to include children above. The // inconsistency of the delete APIs is annoying and should // be fixed (it should only take an id) deletedIds.map(async id => { await db.deleteTransaction({ id }); }), ); } if (updated) { await Promise.all( updated.map(async t => { if (t.account) { // Moving transactions off budget should always clear the // category const account = accounts.find(acct => acct.id === t.account); if (account.offbudget === 1) { t.category = null; } } await db.updateTransaction(t); }), ); } }); // Get all of the full transactions that were changed. This is // needed to run any cascading logic that depends on the full // transaction. Things like transfers, analyzing rule updates, and // more const allAdded = await getTransactionsByIds(addedIds); const allUpdated = await getTransactionsByIds(updatedIds); const allDeleted = await getTransactionsByIds(deletedIds); // Post-processing phase: first do any updates to transfers. // Transfers update the transactions and we need to return updates // to the client so that can apply them. Note that added // transactions just return the full transaction. const resultAdded = allAdded; const resultUpdated = allUpdated; let transfersUpdated: Awaited<ReturnType<typeof transfer.onUpdate>>[]; if (runTransfers) { await batchMessages(async () => { await Promise.all(allAdded.map(t => transfer.onInsert(t))); // Return any updates from here transfersUpdated = ( await Promise.all(allUpdated.map(t => transfer.onUpdate(t))) ).filter(Boolean); await Promise.all(allDeleted.map(t => transfer.onDelete(t))); }); } if (learnCategories) { // Analyze any updated categories and update rules to learn from // the user's activity const ids = new Set([ ...(added ? added.filter(add => add.category).map(add => add.id) : []), ...(updated ? updated.filter(update => update.category).map(update => update.id) : []), ]); await rules.updateCategoryRules( allAdded.concat(allUpdated).filter(trans => ids.has(trans.id)), ); } if (detectOrphanPayees) { // Look for any orphaned payees and notify the user about merging // them if (updated) { const newPayeeIds = updated.map(u => u.payee).filter(Boolean); if (newPayeeIds.length > 0) { const allOrphaned = new Set(await db.getOrphanedPayees()); const orphanedIds = [...oldPayees].filter(id => allOrphaned.has(id)); if (orphanedIds.length > 0) { connection.send('orphaned-payees', { orphanedIds, updatedPayeeIds: newPayeeIds, }); } } } } return { added: resultAdded, updated: runTransfers ? transfersUpdated : resultUpdated, }; }