Skip to content
Snippets Groups Projects
api.ts 15.8 KiB
Newer Older
  • Learn to ignore specific revisions
  • 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,
    
    } from '../shared/transactions';
    import { integerToAmount } from '../shared/util';
    
    import { Handlers } from '../types/handlers';
    import { ServerHandlers } from '../types/server-handlers';
    
    James Long's avatar
    James Long committed
    import { addTransactions } from './accounts/sync';
    import {
      accountModel,
      categoryModel,
      categoryGroupModel,
      payeeModel,
    } from './api-models';
    
    import { runQuery as aqlQuery } from './aql';
    
    James Long's avatar
    James Long committed
    import * as cloudStorage from './cloud-storage';
    
    import * as db from './db';
    
    James Long's avatar
    James Long committed
    import { runMutator } from './mutators';
    
    import * as prefs from './prefs';
    import * as sheet from './sheet';
    import { setSyncingMode, batchMessages } from './sync';
    
    James Long's avatar
    James Long committed
    
    let IMPORT_MODE = false;
    
    // This is duplicate from main.js...
    
    function APIError(msg, meta?) {
    
    James Long's avatar
    James Long committed
      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);
    
    James Long's avatar
    James Long committed
    
    
    James Long's avatar
    James Long committed
              'SELECT DISTINCT dataset FROM messages_crdt WHERE timestamp > ?',
    
    James Long's avatar
    James Long committed
            );
    
            // 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),
    
    James Long's avatar
    James Long committed
              });
            }
    
            return result;
          },
    
          { undoDisabled: true },
    
    let handlers = {} as unknown as Handlers;
    
    James Long's avatar
    James Long committed
    
    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);
    
    James Long's avatar
    James Long committed
        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 = ?', [
    
    James Long's avatar
    James Long committed
      ]);
    
      if (!row) {
    
        throw APIError(`${debug}: category “${id}” does not exist`);
    
    James Long's avatar
    James Long committed
      }
    
      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');
      }
    }
    
    
    James Long's avatar
    James Long committed
    let batchPromise = null;
    
    
    handlers['api/batch-budget-start'] = async function () {
    
    James Long's avatar
    James Long committed
      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 () {
    
    James Long's avatar
    James Long committed
      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() || {};
    
    James Long's avatar
    James Long committed
    
      if (currentId !== id) {
        connection.send('start-load');
    
        const { error } = await handlers['load-budget']({ id });
    
    James Long's avatar
    James Long committed
    
        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 }) {
    
    James Long's avatar
    James Long committed
      // 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 () {
    
    James Long's avatar
    James Long committed
      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
    
    James Long's avatar
    James Long committed
      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 () {
    
    James Long's avatar
    James Long committed
      if (IMPORT_MODE) {
    
    James Long's avatar
    James Long committed
    
        await handlers['close-budget']();
        await handlers['delete-budget']({ id });
        connection.send('show-budgets');
      }
    
      IMPORT_MODE = false;
    };
    
    
    handlers['api/query'] = async function ({ query }) {
    
    James Long's avatar
    James Long committed
      return aqlQuery(query);
    };
    
    
    handlers['api/budget-months'] = async function () {
    
      const { start, end } = await handlers['get-budget-bounds']();
    
    James Long's avatar
    James Long committed
      return monthUtils.range(start, end);
    };
    
    
    handlers['api/budget-month'] = async function ({ month }) {
    
    James Long's avatar
    James Long committed
      await validateMonth(month);
    
    
      const groups = await db.getCategoriesGrouped();
      const sheetName = monthUtils.sheetForMonth(month);
    
    James Long's avatar
    James Long committed
    
      function value(name) {
    
        const v = sheet.get().getCellValue(sheetName, name);
    
    James Long's avatar
    James Long committed
        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,
    
    James Long's avatar
    James Long committed
    
        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}`),
              })),
    
    James Long's avatar
    James Long committed
            };
          }
    
          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}`),
            })),
    
    James Long's avatar
    James Long committed
          };
    
    handlers['api/budget-set-amount'] = withMutation(async function ({
    
    James Long's avatar
    James Long committed
      month,
      categoryId,
    
    James Long's avatar
    James Long committed
    }) {
    
    James Long's avatar
    James Long committed
      return handlers['budget/budget-amount']({
        month,
        category: categoryId,
    
    handlers['api/budget-set-carryover'] = withMutation(async function ({
    
    James Long's avatar
    James Long committed
      month,
      categoryId,
    
    James Long's avatar
    James Long committed
    }) {
    
    James Long's avatar
    James Long committed
      await validateMonth(month);
      await validateExpenseCategory('budget-set-carryover', categoryId);
      return handlers['budget/set-carryover']({
        startMonth: month,
        category: categoryId,
    
    handlers['api/transactions-export'] = async function ({
    
    James Long's avatar
    James Long committed
      transactions,
      categoryGroups,
    
    James Long's avatar
    James Long committed
    }) {
    
    James Long's avatar
    James Long committed
      return handlers['transactions-export']({
        transactions,
        categoryGroups,
    
    handlers['api/transactions-import'] = withMutation(async function ({
    
    James Long's avatar
    James Long committed
      accountId,
    
    James Long's avatar
    James Long committed
    }) {
    
    James Long's avatar
    James Long committed
      return handlers['transactions-import']({ accountId, transactions });
    });
    
    
    handlers['api/transactions-add'] = withMutation(async function ({
    
    James Long's avatar
    James Long committed
      accountId,
    
      runTransfers = false,
      learnCategories = false,
    
    James Long's avatar
    James Long committed
    }) {
    
      await addTransactions(accountId, transactions, {
        runTransfers,
        learnCategories,
      });
    
    James Long's avatar
    James Long committed
      return 'ok';
    });
    
    
    handlers['api/transactions-get'] = async function ({
    
    James Long's avatar
    James Long committed
      accountId,
      startDate,
    
    James Long's avatar
    James Long committed
    }) {
    
    James Long's avatar
    James Long committed
        q('transactions')
          .filter({
            $and: [
              accountId && { account: accountId },
              startDate && { date: { $gte: startDate } },
    
              endDate && { date: { $lte: endDate } },
            ].filter(Boolean),
    
    James Long's avatar
    James Long committed
          })
          .select('*')
    
          .options({ splits: 'grouped' }),
    
    James Long's avatar
    James Long committed
      );
      return data;
    };
    
    
    handlers['api/transactions-filter'] = async function ({ text, accountId }) {
    
    James Long's avatar
    James Long committed
      throw new Error('`filterTransactions` is deprecated, use `runQuery` instead');
    };
    
    
    handlers['api/transaction-update'] = withMutation(async function ({
    
    James Long's avatar
    James Long committed
      id,
    
    James Long's avatar
    James Long committed
    }) {
    
        q('transactions').filter({ id }).select('*').options({ splits: 'grouped' }),
    
    James Long's avatar
    James Long committed
      );
    
      const transactions = ungroupTransactions(data);
    
    James Long's avatar
    James Long committed
    
      if (transactions.length === 0) {
        return [];
      }
    
    
      const { diff } = updateTransaction(transactions, { id, ...fields });
    
    James Long's avatar
    James Long committed
      return handlers['transactions-batch-update'](diff);
    });
    
    
    handlers['api/transaction-delete'] = withMutation(async function ({ id }) {
    
        q('transactions').filter({ id }).select('*').options({ splits: 'grouped' }),
    
    James Long's avatar
    James Long committed
      );
    
      const transactions = ungroupTransactions(data);
    
    James Long's avatar
    James Long committed
    
      if (transactions.length === 0) {
        return [];
      }
    
    
      const { diff } = deleteTransaction(transactions, id);
    
    James Long's avatar
    James Long committed
      return handlers['transactions-batch-update'](diff);
    });
    
    
    handlers['api/accounts-get'] = async function () {
    
      const accounts = await db.getAccounts();
    
    James Long's avatar
    James Long committed
      return accounts.map(account => accountModel.toExternal(account));
    };
    
    
    handlers['api/account-create'] = withMutation(async function ({
    
    James Long's avatar
    James Long committed
      account,
    
    James Long's avatar
    James Long committed
    }) {
    
    James Long's avatar
    James Long committed
      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 }) {
    
    James Long's avatar
    James Long committed
      return db.updateAccount({ id, ...accountModel.fromExternal(fields) });
    });
    
    
    handlers['api/account-close'] = withMutation(async function ({
    
    James Long's avatar
    James Long committed
      id,
      transferAccountId,
    
    James Long's avatar
    James Long committed
    }) {
    
    James Long's avatar
    James Long committed
      return handlers['account-close']({
        id,
        transferAccountId,
    
        categoryId: transferCategoryId,
    
    handlers['api/account-reopen'] = withMutation(async function ({ id }) {
    
    James Long's avatar
    James Long committed
      return handlers['account-reopen']({ id });
    });
    
    
    handlers['api/account-delete'] = withMutation(async function ({ id }) {
    
    James Long's avatar
    James Long committed
      return handlers['account-close']({ id, forced: true });
    });
    
    
    handlers['api/categories-get'] = async function ({
      grouped,
    }: { grouped? } = {}) {
    
      const result = await handlers['get-categories']();
    
    James Long's avatar
    James Long committed
      return grouped
        ? result.grouped.map(categoryGroupModel.toExternal)
        : result.list.map(categoryModel.toExternal);
    };
    
    
    handlers['api/category-group-create'] = withMutation(async function ({
    
    James Long's avatar
    James Long committed
      return handlers['category-group-create']({ name: group.name });
    });
    
    
    handlers['api/category-group-update'] = withMutation(async function ({
    
    James Long's avatar
    James Long committed
      id,
    
    James Long's avatar
    James Long committed
    }) {
    
    James Long's avatar
    James Long committed
      return handlers['category-group-update']({
        id,
    
        ...categoryGroupModel.fromExternal(fields),
    
    handlers['api/category-group-delete'] = withMutation(async function ({
    
    James Long's avatar
    James Long committed
      id,
    
    James Long's avatar
    James Long committed
    }) {
    
    James Long's avatar
    James Long committed
      return handlers['category-group-delete']({
        id,
    
        transferId: transferCategoryId,
    
    handlers['api/category-create'] = withMutation(async function ({ category }) {
    
    James Long's avatar
    James Long committed
      return handlers['category-create']({
        name: category.name,
        groupId: category.group_id,
    
        isIncome: category.is_income,
    
    handlers['api/category-update'] = withMutation(async function ({ id, fields }) {
    
    James Long's avatar
    James Long committed
      return handlers['category-update']({
        id,
    
        ...categoryModel.fromExternal(fields),
    
    handlers['api/category-delete'] = withMutation(async function ({
    
    James Long's avatar
    James Long committed
      id,
    
    James Long's avatar
    James Long committed
    }) {
    
    James Long's avatar
    James Long committed
      return handlers['category-delete']({
        id,
    
        transferId: transferCategoryId,
    
    handlers['api/payees-get'] = async function () {
    
      const payees = await handlers['payees-get']();
    
    James Long's avatar
    James Long committed
      return payees.map(payeeModel.toExternal);
    };
    
    
    handlers['api/payee-create'] = withMutation(async function ({ payee }) {
    
    James Long's avatar
    James Long committed
      return handlers['payee-create']({ name: payee.name });
    });
    
    
    handlers['api/payee-update'] = withMutation(async function ({ id, fields }) {
    
    James Long's avatar
    James Long committed
      return handlers['payees-batch-change']({
    
        updated: [{ id, ...payeeModel.fromExternal(fields) }],
    
    handlers['api/payee-delete'] = withMutation(async function ({ id }) {
    
    James Long's avatar
    James Long committed
      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;
    
    James Long's avatar
    James Long committed
    }