import { getClock } from '@actual-app/crdt'; import * as connection from '../platform/server/connection'; import { getDownloadError, getSyncError, getTestKeyError, } from '../shared/errors'; import * as monthUtils from '../shared/months'; import q from '../shared/query'; import { ungroupTransactions, updateTransaction, deleteTransaction, } from '../shared/transactions'; import { integerToAmount } from '../shared/util'; import { Handlers } from '../types/handlers'; import { ServerHandlers } from '../types/server-handlers'; import { addTransactions } from './accounts/sync'; import { accountModel, categoryModel, categoryGroupModel, payeeModel, } from './api-models'; import { runQuery as aqlQuery } from './aql'; import * as cloudStorage from './cloud-storage'; import * as db from './db'; import { runMutator } from './mutators'; import * as prefs from './prefs'; import * as sheet from './sheet'; import { setSyncingMode, batchMessages } from './sync'; let IMPORT_MODE = false; // This is duplicate from main.js... function APIError(msg, meta?) { return { type: 'APIError', message: msg, meta }; } // The API is different in two ways: we never want undo enabled, and // we also need to notify the UI manually if stuff has changed (if // they are connecting to an already running instance, the UI should // update). The wrapper handles that. function withMutation(handler) { return args => { return runMutator( async () => { const latestTimestamp = getClock().timestamp.toString(); const result = await handler(args); const rows = await db.all( 'SELECT DISTINCT dataset FROM messages_crdt WHERE timestamp > ?', [latestTimestamp], ); // Only send the sync event if anybody else is connected if (connection.getNumClients() > 1) { connection.send('sync-event', { type: 'success', tables: rows.map(row => row.dataset), }); } return result; }, { undoDisabled: true }, ); }; } let handlers = {} as unknown as Handlers; async function validateMonth(month) { if (!month.match(/^\d{4}-\d{2}$/)) { throw APIError('Invalid month format, use YYYY-MM: ' + month); } if (!IMPORT_MODE) { const { start, end } = await handlers['get-budget-bounds'](); const range = monthUtils.range(start, end); if (!range.includes(month)) { throw APIError('No budget exists for month: ' + month); } } } async function validateExpenseCategory(debug, id) { if (id == null) { throw APIError(`${debug}: category id is required`); } const row = await db.first('SELECT is_income FROM categories WHERE id = ?', [ id, ]); if (!row) { throw APIError(`${debug}: category “${id}” does not exist`); } if (row.is_income !== 0) { throw APIError(`${debug}: category “${id}” is not an expense category`); } } function checkFileOpen() { if (!(prefs.getPrefs() || {}).id) { throw APIError('No budget file is open'); } } let batchPromise = null; handlers['api/batch-budget-start'] = async function () { if (batchPromise) { throw APIError('Cannot start a batch process: batch already started'); } // If we are importing, all we need to do is start a raw database // transaction. Updating spreadsheet cells doesn't go through the // syncing layer in that case. if (IMPORT_MODE) { db.asyncTransaction(() => { return new Promise((resolve, reject) => { batchPromise = { resolve, reject }; }); }); } else { batchMessages(() => { return new Promise((resolve, reject) => { batchPromise = { resolve, reject }; }); }); } }; handlers['api/batch-budget-end'] = async function () { if (!batchPromise) { throw APIError('Cannot end a batch process: no batch started'); } batchPromise.resolve(); batchPromise = null; }; handlers['api/load-budget'] = async function ({ id }) { const { id: currentId } = prefs.getPrefs() || {}; if (currentId !== id) { connection.send('start-load'); const { error } = await handlers['load-budget']({ id }); if (!error) { connection.send('finish-load'); } else { connection.send('show-budgets'); throw new Error(getSyncError(error, id)); } } }; handlers['api/download-budget'] = async function ({ syncId, password }) { const { id: currentId } = prefs.getPrefs() || {}; if (currentId) { await handlers['close-budget'](); } const localBudget = (await handlers['get-budgets']()).find( b => b.groupId === syncId, ); if (localBudget) { await handlers['load-budget']({ id: localBudget.id }); const result = await handlers['sync-budget'](); if (result.error) { throw new Error(getSyncError(result.error, localBudget.id)); } } else { const files = await handlers['get-remote-files'](); if (!files) { throw new Error('Could not get remote files'); } const file = files.find(f => f.groupId === syncId); if (!file) { throw new Error( `Budget “${syncId}” not found. Check the sync id of your budget in the Advanced section of the settings page.`, ); } if (file.encryptKeyId && !password) { throw new Error( `File ${file.name} is encrypted. Please provide a password.`, ); } if (password) { const result = await handlers['key-test']({ fileId: file.fileId, password, }); if (result.error) { throw new Error(getTestKeyError(result.error)); } } const result = await handlers['download-budget']({ fileId: file.fileId }); if (result.error) { console.log('Full error details', result.error); throw new Error(getDownloadError(result.error)); } await handlers['load-budget']({ id: result.id }); } }; handlers['api/sync'] = async function () { const { id } = prefs.getPrefs(); const result = await handlers['sync-budget'](); if (result.error) { throw new Error(getSyncError(result.error, id)); } }; handlers['api/start-import'] = async function ({ budgetName }) { // Notify UI to close budget await handlers['close-budget'](); // Create the budget await handlers['create-budget']({ budgetName, avoidUpload: true }); // Clear out the default expense categories await db.runQuery('DELETE FROM categories WHERE is_income = 0'); await db.runQuery('DELETE FROM category_groups WHERE is_income = 0'); // Turn syncing off setSyncingMode('import'); connection.send('start-import'); IMPORT_MODE = true; }; handlers['api/finish-import'] = async function () { checkFileOpen(); sheet.get().markCacheDirty(); // We always need to fully reload the app. Importing doesn't touch // the spreadsheet, but we can't just recreate the spreadsheet // either; there is other internal state that isn't created const { id } = prefs.getPrefs(); await handlers['close-budget'](); await handlers['load-budget']({ id }); await handlers['get-budget-bounds'](); await sheet.waitOnSpreadsheet(); await cloudStorage.upload().catch(err => {}); connection.send('finish-import'); IMPORT_MODE = false; }; handlers['api/abort-import'] = async function () { if (IMPORT_MODE) { checkFileOpen(); const { id } = prefs.getPrefs(); await handlers['close-budget'](); await handlers['delete-budget']({ id }); connection.send('show-budgets'); } IMPORT_MODE = false; }; handlers['api/query'] = async function ({ query }) { checkFileOpen(); return aqlQuery(query); }; handlers['api/budget-months'] = async function () { checkFileOpen(); const { start, end } = await handlers['get-budget-bounds'](); return monthUtils.range(start, end); }; handlers['api/budget-month'] = async function ({ month }) { checkFileOpen(); await validateMonth(month); const groups = await db.getCategoriesGrouped(); const sheetName = monthUtils.sheetForMonth(month); function value(name) { const v = sheet.get().getCellValue(sheetName, name); return v === '' ? 0 : v; } // This is duplicated from main.js because the return format is // different (for now) return { month, incomeAvailable: value('available-funds') as number, lastMonthOverspent: value('last-month-overspent') as number, forNextMonth: value('buffered') as number, totalBudgeted: value('total-budgeted') as number, toBudget: value('to-budget') as number, fromLastMonth: value('from-last-month') as number, totalIncome: value('total-income') as number, totalSpent: value('total-spent') as number, totalBalance: value('total-leftover') as number, categoryGroups: groups.map(group => { if (group.is_income) { return { ...categoryGroupModel.toExternal(group), received: value('total-income'), categories: group.categories.map(cat => ({ ...categoryModel.toExternal(cat), received: value(`sum-amount-${cat.id}`), })), }; } return { ...categoryGroupModel.toExternal(group), budgeted: value(`group-budget-${group.id}`), spent: value(`group-sum-amount-${group.id}`), balance: value(`group-leftover-${group.id}`), categories: group.categories.map(cat => ({ ...categoryModel.toExternal(cat), budgeted: value(`budget-${cat.id}`), spent: value(`sum-amount-${cat.id}`), balance: value(`leftover-${cat.id}`), carryover: value(`carryover-${cat.id}`), })), }; }), }; }; handlers['api/budget-set-amount'] = withMutation(async function ({ month, categoryId, amount, }) { checkFileOpen(); return handlers['budget/budget-amount']({ month, category: categoryId, amount, }); }); handlers['api/budget-set-carryover'] = withMutation(async function ({ month, categoryId, flag, }) { checkFileOpen(); await validateMonth(month); await validateExpenseCategory('budget-set-carryover', categoryId); return handlers['budget/set-carryover']({ startMonth: month, category: categoryId, flag, }); }); handlers['api/transactions-export'] = async function ({ transactions, categoryGroups, payees, }) { checkFileOpen(); return handlers['transactions-export']({ transactions, categoryGroups, payees, }); }; handlers['api/transactions-import'] = withMutation(async function ({ accountId, transactions, }) { checkFileOpen(); return handlers['transactions-import']({ accountId, transactions }); }); handlers['api/transactions-add'] = withMutation(async function ({ accountId, transactions, runTransfers = false, learnCategories = false, }) { checkFileOpen(); await addTransactions(accountId, transactions, { runTransfers, learnCategories, }); return 'ok'; }); handlers['api/transactions-get'] = async function ({ accountId, startDate, endDate, }) { checkFileOpen(); const { data } = await aqlQuery( q('transactions') .filter({ $and: [ accountId && { account: accountId }, startDate && { date: { $gte: startDate } }, endDate && { date: { $lte: endDate } }, ].filter(Boolean), }) .select('*') .options({ splits: 'grouped' }), ); return data; }; handlers['api/transactions-filter'] = async function ({ text, accountId }) { throw new Error('`filterTransactions` is deprecated, use `runQuery` instead'); }; handlers['api/transaction-update'] = withMutation(async function ({ id, fields, }) { checkFileOpen(); const { data } = await aqlQuery( q('transactions').filter({ id }).select('*').options({ splits: 'grouped' }), ); const transactions = ungroupTransactions(data); if (transactions.length === 0) { return []; } const { diff } = updateTransaction(transactions, { id, ...fields }); return handlers['transactions-batch-update'](diff); }); handlers['api/transaction-delete'] = withMutation(async function ({ id }) { checkFileOpen(); const { data } = await aqlQuery( q('transactions').filter({ id }).select('*').options({ splits: 'grouped' }), ); const transactions = ungroupTransactions(data); if (transactions.length === 0) { return []; } const { diff } = deleteTransaction(transactions, id); return handlers['transactions-batch-update'](diff); }); handlers['api/accounts-get'] = async function () { checkFileOpen(); const accounts = await db.getAccounts(); return accounts.map(account => accountModel.toExternal(account)); }; handlers['api/account-create'] = withMutation(async function ({ account, initialBalance = null, }) { checkFileOpen(); return handlers['account-create']({ name: account.name, offBudget: account.offbudget, closed: account.closed, // Current the API expects an amount but it really should expect // an integer balance: initialBalance != null ? integerToAmount(initialBalance) : null, }); }); handlers['api/account-update'] = withMutation(async function ({ id, fields }) { checkFileOpen(); return db.updateAccount({ id, ...accountModel.fromExternal(fields) }); }); handlers['api/account-close'] = withMutation(async function ({ id, transferAccountId, transferCategoryId, }) { checkFileOpen(); return handlers['account-close']({ id, transferAccountId, categoryId: transferCategoryId, }); }); handlers['api/account-reopen'] = withMutation(async function ({ id }) { checkFileOpen(); return handlers['account-reopen']({ id }); }); handlers['api/account-delete'] = withMutation(async function ({ id }) { checkFileOpen(); return handlers['account-close']({ id, forced: true }); }); handlers['api/categories-get'] = async function ({ grouped, }: { grouped? } = {}) { checkFileOpen(); const result = await handlers['get-categories'](); return grouped ? result.grouped.map(categoryGroupModel.toExternal) : result.list.map(categoryModel.toExternal); }; handlers['api/category-group-create'] = withMutation(async function ({ group, }) { checkFileOpen(); return handlers['category-group-create']({ name: group.name }); }); handlers['api/category-group-update'] = withMutation(async function ({ id, fields, }) { checkFileOpen(); return handlers['category-group-update']({ id, ...categoryGroupModel.fromExternal(fields), }); }); handlers['api/category-group-delete'] = withMutation(async function ({ id, transferCategoryId, }) { checkFileOpen(); return handlers['category-group-delete']({ id, transferId: transferCategoryId, }); }); handlers['api/category-create'] = withMutation(async function ({ category }) { checkFileOpen(); return handlers['category-create']({ name: category.name, groupId: category.group_id, isIncome: category.is_income, hidden: category.hidden, }); }); handlers['api/category-update'] = withMutation(async function ({ id, fields }) { checkFileOpen(); return handlers['category-update']({ id, ...categoryModel.fromExternal(fields), }); }); handlers['api/category-delete'] = withMutation(async function ({ id, transferCategoryId, }) { checkFileOpen(); return handlers['category-delete']({ id, transferId: transferCategoryId, }); }); handlers['api/payees-get'] = async function () { checkFileOpen(); const payees = await handlers['payees-get'](); return payees.map(payeeModel.toExternal); }; handlers['api/payee-create'] = withMutation(async function ({ payee }) { checkFileOpen(); return handlers['payee-create']({ name: payee.name }); }); handlers['api/payee-update'] = withMutation(async function ({ id, fields }) { checkFileOpen(); return handlers['payees-batch-change']({ updated: [{ id, ...payeeModel.fromExternal(fields) }], }); }); handlers['api/payee-delete'] = withMutation(async function ({ id }) { checkFileOpen(); return handlers['payees-batch-change']({ deleted: [{ id }] }); }); export default function installAPI(serverHandlers: ServerHandlers) { const merged = Object.assign({}, serverHandlers, handlers); handlers = merged as Handlers; return merged; }