-
Matiss Janis Aboltins authoredMatiss Janis Aboltins authored
transactions.ts 5.57 KiB
// @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,
};
}