Skip to content
Snippets Groups Projects
main.ts 53.27 KiB
// @ts-strict-ignore
import './polyfills';
import * as injectAPI from '@actual-app/api/injected';
import * as CRDT from '@actual-app/crdt';
import { v4 as uuidv4 } from 'uuid';

import { createTestBudget } from '../mocks/budget';
import { captureException, captureBreadcrumb } from '../platform/exceptions';
import * as asyncStorage from '../platform/server/asyncStorage';
import * as connection from '../platform/server/connection';
import * as fs from '../platform/server/fs';
import { logger } from '../platform/server/log';
import * as sqlite from '../platform/server/sqlite';
import { isNonProductionEnvironment } from '../shared/environment';
import * as monthUtils from '../shared/months';
import { q, Query } from '../shared/query';
import { amountToInteger, stringToInteger } from '../shared/util';
import { Handlers } from '../types/handlers';

import { exportToCSV, exportQueryToCSV } from './accounts/export-to-csv';
import * as link from './accounts/link';
import { parseFile } from './accounts/parse-file';
import { getStartingBalancePayee } from './accounts/payees';
import * as bankSync from './accounts/sync';
import * as rules from './accounts/transaction-rules';
import { batchUpdateTransactions } from './accounts/transactions';
import { installAPI } from './api';
import { runQuery as aqlQuery } from './aql';
import {
  getAvailableBackups,
  loadBackup,
  makeBackup,
  startBackupService,
  stopBackupService,
} from './backups';
import { app as budgetApp } from './budget/app';
import * as budget from './budget/base';
import * as cloudStorage from './cloud-storage';
import * as db from './db';
import * as mappings from './db/mappings';
import * as encryption from './encryption';
import { APIError, TransactionError, PostError } from './errors';
import { app as filtersApp } from './filters/app';
import { handleBudgetImport } from './importers';
import { app } from './main-app';
import { mutator, runHandler } from './mutators';
import { app as notesApp } from './notes/app';
import * as Platform from './platform';
import { get, post } from './post';
import * as prefs from './prefs';
import { app as reportsApp } from './reports/app';
import { app as rulesApp } from './rules/app';
import { app as schedulesApp } from './schedules/app';
import { getServer, setServer } from './server-config';
import * as sheet from './sheet';
import { resolveName, unresolveName } from './spreadsheet/util';
import {
  initialFullSync,
  fullSync,
  batchMessages,
  setSyncingMode,
  makeTestMessage,
  clearFullSyncTimeout,
  resetSync,
  repairSync,
} from './sync';
import * as syncMigrations from './sync/migrate';
import { app as toolsApp } from './tools/app';
import { withUndo, clearUndo, undo, redo } from './undo';
import { updateVersion } from './update';
import { uniqueFileName, idFromFileName } from './util/budget-name';

const DEMO_BUDGET_ID = '_demo-budget';
const TEST_BUDGET_ID = '_test-budget';

// util

function onSheetChange({ names }) {
  const nodes = names.map(name => {
    const node = sheet.get()._getNode(name);
    return { name: node.name, value: node.value };
  });
  connection.send('cells-changed', nodes);
}

// handlers

// need to work around the type system here because the object
// is /currently/ empty but we promise to fill it in later
export let handlers = {} as unknown as Handlers;

handlers['undo'] = mutator(async function () {
  return undo();
});

handlers['redo'] = mutator(function () {
  return redo();
});

handlers['transactions-batch-update'] = mutator(async function ({
  added,
  deleted,
  updated,
  learnCategories,
}) {
  return withUndo(async () => {
    const result = await batchUpdateTransactions({
      added,
      updated,
      deleted,
      learnCategories,
    });

    // Return all data updates to the frontend
    return result.updated;
  });
});

handlers['transaction-add'] = mutator(async function (transaction) {
  await handlers['transactions-batch-update']({ added: [transaction] });
  return {};
});

handlers['transaction-update'] = mutator(async function (transaction) {
  await handlers['transactions-batch-update']({ updated: [transaction] });
  return {};
});

handlers['transaction-delete'] = mutator(async function (transaction) {
  await handlers['transactions-batch-update']({ deleted: [transaction] });
  return {};
});

handlers['transactions-parse-file'] = async function ({ filepath, options }) {
  return parseFile(filepath, options);
};

handlers['transactions-export'] = async function ({
  transactions,
  accounts,
  categoryGroups,
  payees,
}) {
  return exportToCSV(transactions, accounts, categoryGroups, payees);
};

handlers['transactions-export-query'] = async function ({ query: queryState }) {
  return exportQueryToCSV(new Query(queryState));
};

handlers['get-categories'] = async function () {
  return {
    grouped: await db.getCategoriesGrouped(),
    list: await db.getCategories(),
  };
};

handlers['get-earliest-transaction'] = async function () {
  const { data } = await aqlQuery(
    q('transactions')
      .options({ splits: 'none' })
      .orderBy({ date: 'asc' })
      .select('*')
      .limit(1),
  );
  return data[0] || null;
};

handlers['get-budget-bounds'] = async function () {
  return budget.createAllBudgets();
};

handlers['rollover-budget-month'] = async function ({ month }) {
  const groups = await db.getCategoriesGrouped();
  const sheetName = monthUtils.sheetForMonth(month);

  function value(name) {
    const v = sheet.getCellValue(sheetName, name);
    return { value: v === '' ? 0 : v, name: resolveName(sheetName, name) };
  }

  let values = [
    value('available-funds'),
    value('last-month-overspent'),
    value('buffered'),
    value('total-budgeted'),
    value('to-budget'),

    value('from-last-month'),
    value('total-income'),
    value('total-spent'),
    value('total-leftover'),
  ];

  for (const group of groups) {
    if (group.is_income) {
      values.push(value('total-income'));

      for (const cat of group.categories) {
        values.push(value(`sum-amount-${cat.id}`));
      }
    } else {
      values = values.concat([
        value(`group-budget-${group.id}`),
        value(`group-sum-amount-${group.id}`),
        value(`group-leftover-${group.id}`),
      ]);

      for (const cat of group.categories) {
        values = values.concat([
          value(`budget-${cat.id}`),
          value(`sum-amount-${cat.id}`),
          value(`leftover-${cat.id}`),
          value(`carryover-${cat.id}`),
        ]);
      }
    }
  }

  return values;
};

handlers['report-budget-month'] = async function ({ month }) {
  const groups = await db.getCategoriesGrouped();
  const sheetName = monthUtils.sheetForMonth(month);

  function value(name) {
    const v = sheet.getCellValue(sheetName, name);
    return { value: v === '' ? 0 : v, name: resolveName(sheetName, name) };
  }

  let values = [
    value('total-budgeted'),
    value('total-budget-income'),
    value('total-saved'),
    value('total-income'),
    value('total-spent'),
    value('real-saved'),
    value('total-leftover'),
  ];

  for (const group of groups) {
    values = values.concat([
      value(`group-budget-${group.id}`),
      value(`group-sum-amount-${group.id}`),
      value(`group-leftover-${group.id}`),
    ]);

    for (const cat of group.categories) {
      values = values.concat([
        value(`budget-${cat.id}`),
        value(`sum-amount-${cat.id}`),
        value(`leftover-${cat.id}`),
      ]);

      if (!group.is_income) {
        values.push(value(`carryover-${cat.id}`));
      }
    }
  }

  return values;
};

handlers['budget-set-type'] = async function ({ type }) {
  if (!prefs.BUDGET_TYPES.includes(type)) {
    throw new Error('Invalid budget type: ' + type);
  }

  // It's already the same; don't do anything
  if (type === prefs.getPrefs().budgetType) {
    return;
  }

  // Save prefs
  return prefs.savePrefs({ budgetType: type });
};

handlers['category-create'] = mutator(async function ({
  name,
  groupId,
  isIncome,
  hidden,
}) {
  return withUndo(async () => {
    if (!groupId) {
      throw APIError('Creating a category: groupId is required');
    }

    return db.insertCategory({
      name,
      cat_group: groupId,
      is_income: isIncome ? 1 : 0,
      hidden: hidden ? 1 : 0,
    });
  });
});

handlers['category-update'] = mutator(async function (category) {
  return withUndo(async () => {
    try {
      await db.updateCategory(category);
    } catch (e) {
      if (e.message.toLowerCase().includes('unique constraint')) {
        return { error: { type: 'category-exists' } };
      }
      throw e;
    }
    return {};
  });
});

handlers['category-move'] = mutator(async function ({ id, groupId, targetId }) {
  return withUndo(async () => {
    await batchMessages(async () => {
      await db.moveCategory(id, groupId, targetId);
    });
    return 'ok';
  });
});

handlers['category-delete'] = mutator(async function ({ id, transferId }) {
  return withUndo(async () => {
    let result = {};
    await batchMessages(async () => {
      const row = await db.first(
        'SELECT is_income FROM categories WHERE id = ?',
        [id],
      );
      if (!row) {
        result = { error: 'no-categories' };
        return;
      }

      const transfer =
        transferId &&
        (await db.first('SELECT is_income FROM categories WHERE id = ?', [
          transferId,
        ]));

      if (!row || (transferId && !transfer)) {
        result = { error: 'no-categories' };
        return;
      } else if (transferId && row.is_income !== transfer.is_income) {
        result = { error: 'category-type' };
        return;
      }

      // Update spreadsheet values if it's an expense category
      // TODO: We should do this for income too if it's a reflect budget
      if (row.is_income === 0) {
        if (transferId) {
          await budget.doTransfer([id], transferId);
        }
      }

      await db.deleteCategory({ id }, transferId);
    });

    return result;
  });
});

handlers['get-category-groups'] = async function () {
  return await db.getCategoriesGrouped();
};

handlers['category-group-create'] = mutator(async function ({
  name,
  isIncome,
}) {
  return withUndo(async () => {
    return db.insertCategoryGroup({
      name,
      is_income: isIncome ? 1 : 0,
    });
  });
});

handlers['category-group-update'] = mutator(async function (group) {
  return withUndo(async () => {
    return db.updateCategoryGroup(group);
  });
});

handlers['category-group-move'] = mutator(async function ({ id, targetId }) {
  return withUndo(async () => {
    await batchMessages(async () => {
      await db.moveCategoryGroup(id, targetId);
    });
    return 'ok';
  });
});

handlers['category-group-delete'] = mutator(async function ({
  id,
  transferId,
}) {
  return withUndo(async () => {
    const groupCategories = await db.all(
      'SELECT id FROM categories WHERE cat_group = ? AND tombstone = 0',
      [id],
    );

    return batchMessages(async () => {
      if (transferId) {
        await budget.doTransfer(
          groupCategories.map(c => c.id),
          transferId,
        );
      }
      await db.deleteCategoryGroup({ id }, transferId);
    });
  });
});

handlers['must-category-transfer'] = async function ({ id }) {
  const res = await db.runQuery(
    `SELECT count(t.id) as count FROM transactions t
       LEFT JOIN category_mapping cm ON cm.id = t.category
       WHERE cm.transferId = ? AND t.tombstone = 0`,
    [id],
    true,
  );

  // If there are transactions with this category, return early since
  // we already know it needs to be tranferred
  if (res[0].count !== 0) {
    return true;
  }

  // If there are any non-zero budget values, also force the user to
  // transfer the category.
  return [...sheet.get().meta().createdMonths].some(month => {
    const sheetName = monthUtils.sheetForMonth(month);
    const value = sheet.get().getCellValue(sheetName, 'budget-' + id);

    return value != null && value !== 0;
  });
};

handlers['payee-create'] = mutator(async function ({ name }) {
  return withUndo(async () => {
    return db.insertPayee({ name });
  });
});

handlers['payees-get'] = async function () {
  return db.getPayees();
};

handlers['payees-get-orphaned'] = async function () {
  return db.syncGetOrphanedPayees();
};

handlers['payees-get-rule-counts'] = async function () {
  const payeeCounts = {};

  rules.iterateIds(rules.getRules(), 'payee', (rule, id) => {
    if (payeeCounts[id] == null) {
      payeeCounts[id] = 0;
    }
    payeeCounts[id]++;
  });

  return payeeCounts;
};

handlers['payees-merge'] = mutator(async function ({ targetId, mergeIds }) {
  return withUndo(
    async () => {
      return db.mergePayees(targetId, mergeIds);
    },
    { targetId, mergeIds },
  );
});

handlers['payees-batch-change'] = mutator(async function ({
  added,
  deleted,
  updated,
}) {
  return withUndo(async () => {
    return batchMessages(async () => {
      if (deleted) {
        await Promise.all(deleted.map(p => db.deletePayee(p)));
      }

      if (added) {
        await Promise.all(added.map(p => db.insertPayee(p)));
      }

      if (updated) {
        await Promise.all(updated.map(p => db.updatePayee(p)));
      }
    });
  });
});

handlers['payees-check-orphaned'] = async function ({ ids }) {
  const orphaned = new Set(await db.getOrphanedPayees());
  return ids.filter(id => orphaned.has(id));
};

handlers['payees-get-rules'] = async function ({ id }) {
  return rules.getRulesForPayee(id).map(rule => rule.serialize());
};

handlers['make-filters-from-conditions'] = async function ({ conditions }) {
  return rules.conditionsToAQL(conditions);
};

handlers['getCell'] = async function ({ sheetName, name }) {
  // Fields is no longer used - hardcode
  const fields = ['name', 'value'];
  const node = sheet.get()._getNode(resolveName(sheetName, name));
  if (fields) {
    const res = {};
    fields.forEach(field => {
      if (field === 'run') {
        res[field] = node._run ? node._run.toString() : null;
      } else {
        res[field] = node[field];
      }
    });
    return res;
  } else {
    return node;
  }
};

handlers['getCells'] = async function ({ names }) {
  return names.map(name => ({ value: sheet.get()._getNode(name).value }));
};

handlers['getCellNamesInSheet'] = async function ({ sheetName }) {
  const names = [];
  for (const name of sheet.get().getNodes().keys()) {
    const { sheet: nodeSheet, name: nodeName } = unresolveName(name);
    if (nodeSheet === sheetName) {
      names.push(nodeName);
    }
  }
  return names;
};

handlers['debugCell'] = async function ({ sheetName, name }) {
  const node = sheet.get().getNode(resolveName(sheetName, name));
  return {
    ...node,
    _run: node._run && node._run.toString(),
  };
};

handlers['create-query'] = async function ({ sheetName, name, query }) {
  // Always run it regardless of cache. We don't know anything has changed
  // between the cache value being saved and now
  sheet.get().createQuery(sheetName, name, query);
  return 'ok';
};

handlers['query'] = async function (query) {
  if (query.table == null) {
    throw new Error('query has no table, did you forgot to call `.serialize`?');
  }

  return aqlQuery(query);
};

handlers['account-update'] = mutator(async function ({ id, name }) {
  return withUndo(async () => {
    await db.update('accounts', { id, name });
    return {};
  });
});

handlers['accounts-get'] = async function () {
  return db.getAccounts();
};

handlers['account-properties'] = async function ({ id }) {
  const { balance } = await db.first(
    'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0',
    [id],
  );
  const { count } = await db.first(
    'SELECT count(id) as count FROM transactions WHERE acct = ? AND tombstone = 0',
    [id],
  );

  return { balance: balance || 0, numTransactions: count };
};

handlers['gocardless-accounts-link'] = async function ({
  requisitionId,
  account,
  upgradingId,
}) {
  let id;
  const bank = await link.findOrCreateBank(account.institution, requisitionId);

  if (upgradingId) {
    const accRow = await db.first('SELECT * FROM accounts WHERE id = ?', [
      upgradingId,
    ]);
    id = accRow.id;
    await db.update('accounts', {
      id,
      account_id: account.account_id,
      bank: bank.id,
      account_sync_source: 'goCardless',
    });
  } else {
    id = uuidv4();
    await db.insertWithUUID('accounts', {
      id,
      account_id: account.account_id,
      mask: account.mask,
      name: account.name,
      official_name: account.official_name,
      bank: bank.id,
      account_sync_source: 'goCardless',
    });
    await db.insertPayee({
      name: '',
      transfer_acct: id,
    });
  }

  await bankSync.syncAccount(
    undefined,
    undefined,
    id,
    account.account_id,
    bank.bank_id,
  );

  connection.send('sync-event', {
    type: 'success',
    tables: ['transactions'],
  });

  return 'ok';
};

handlers['simplefin-accounts-link'] = async function ({
  externalAccount,
  upgradingId,
}) {
  let id;

  const institution = {
    name: externalAccount.institution ?? 'Unknown',
  };

  const bank = await link.findOrCreateBank(
    institution,
    externalAccount.orgDomain,
  );

  if (upgradingId) {
    const accRow = await db.first('SELECT * FROM accounts WHERE id = ?', [
      upgradingId,
    ]);
    id = accRow.id;
    await db.update('accounts', {
      id,
      account_id: externalAccount.account_id,
      bank: bank.id,
      account_sync_source: 'simpleFin',
    });
  } else {
    id = uuidv4();
    await db.insertWithUUID('accounts', {
      id,
      account_id: externalAccount.account_id,
      name: externalAccount.name,
      official_name: externalAccount.name,
      bank: bank.id,
      account_sync_source: 'simpleFin',
    });
    await db.insertPayee({
      name: '',
      transfer_acct: id,
    });
  }

  await bankSync.syncAccount(
    undefined,
    undefined,
    id,
    externalAccount.account_id,
    bank.bank_id,
  );

  await connection.send('sync-event', {
    type: 'success',
    tables: ['transactions'],
  });
  return 'ok';
};

handlers['account-create'] = mutator(async function ({
  name,
  balance,
  offBudget,
  closed,
}) {
  return withUndo(async () => {
    const id = await db.insertAccount({
      name,
      offbudget: offBudget ? 1 : 0,
      closed: closed ? 1 : 0,
    });

    await db.insertPayee({
      name: '',
      transfer_acct: id,
    });

    if (balance != null && balance !== 0) {
      const payee = await getStartingBalancePayee();

      await db.insertTransaction({
        account: id,
        amount: amountToInteger(balance),
        category: offBudget ? null : payee.category,
        payee: payee.id,
        date: monthUtils.currentDay(),
        cleared: true,
        starting_balance_flag: true,
      });
    }

    return id;
  });
});

handlers['account-close'] = mutator(async function ({
  id,
  transferAccountId,
  categoryId,
  forced,
}) {
  // Unlink the account if it's linked. This makes sure to remove it from
  // bank-sync providers. (This should not be undo-able, as it mutates the
  // remote server and the user will have to link the account again)
  await handlers['account-unlink']({ id });

  return withUndo(async () => {
    const account = await db.first(
      'SELECT * FROM accounts WHERE id = ? AND tombstone = 0',
      [id],
    );

    // Do nothing if the account doesn't exist or it's already been
    // closed
    if (!account || account.closed === 1) {
      return;
    }

    const { balance, numTransactions } = await handlers['account-properties']({
      id,
    });

    // If there are no transactions, we can simply delete the account
    if (numTransactions === 0) {
      await db.deleteAccount({ id });
    } else if (forced) {
      const rows = await db.runQuery(
        'SELECT id, transfer_id FROM v_transactions WHERE account = ?',
        [id],
        true,
      );

      const { id: payeeId } = await db.first(
        'SELECT id FROM payees WHERE transfer_acct = ?',
        [id],
      );

      await batchMessages(async () => {
        // TODO: what this should really do is send a special message that
        // automatically marks the tombstone value for all transactions
        // within an account... or something? This is problematic
        // because another client could easily add new data that
        // should be marked as deleted.

        rows.forEach(row => {
          if (row.transfer_id) {
            db.updateTransaction({
              id: row.transfer_id,
              payee: null,
              transfer_id: null,
            });
          }

          db.deleteTransaction({ id: row.id });
        });

        db.deleteAccount({ id });
        db.deleteTransferPayee({ id: payeeId });
      });
    } else {
      if (balance !== 0 && transferAccountId == null) {
        throw APIError('balance is non-zero: transferAccountId is required');
      }

      await db.update('accounts', { id, closed: 1 });

      // If there is a balance we need to transfer it to the specified
      // account (and possibly categorize it)
      if (balance !== 0) {
        const { id: payeeId } = await db.first(
          'SELECT id FROM payees WHERE transfer_acct = ?',
          [transferAccountId],
        );

        await handlers['transaction-add']({
          id: uuidv4(),
          payee: payeeId,
          amount: -balance,
          account: id,
          date: monthUtils.currentDay(),
          notes: 'Closing account',
          category: categoryId || null,
        });
      }
    }
  });
});

handlers['account-reopen'] = mutator(async function ({ id }) {
  return withUndo(async () => {
    await db.update('accounts', { id, closed: 0 });
  });
});

handlers['account-move'] = mutator(async function ({ id, targetId }) {
  return withUndo(async () => {
    await db.moveAccount(id, targetId);
  });
});

let stopPolling = false;

handlers['secret-set'] = async function ({ name, value }) {
  const userToken = await asyncStorage.getItem('user-token');

  if (!userToken) {
    return { error: 'unauthorized' };
  }

  try {
    return await post(
      getServer().BASE_SERVER + '/secret',
      {
        name,
        value,
      },
      {
        'X-ACTUAL-TOKEN': userToken,
      },
    );
  } catch (error) {
    console.error(error);
    return { error: 'failed' };
  }
};

handlers['secret-check'] = async function (name) {
  const userToken = await asyncStorage.getItem('user-token');

  if (!userToken) {
    return { error: 'unauthorized' };
  }

  try {
    return await get(getServer().BASE_SERVER + '/secret/' + name, {
      'X-ACTUAL-TOKEN': userToken,
    });
  } catch (error) {
    console.error(error);
    return { error: 'failed' };
  }
};

handlers['gocardless-poll-web-token'] = async function ({
  upgradingAccountId,
  requisitionId,
}) {
  const userToken = await asyncStorage.getItem('user-token');
  if (!userToken) return { error: 'unknown' };

  const startTime = Date.now();
  stopPolling = false;

  async function getData(cb) {
    if (stopPolling) {
      return;
    }

    if (Date.now() - startTime >= 1000 * 60 * 10) {
      cb('timeout');
      return;
    }

    const data = await post(
      getServer().GOCARDLESS_SERVER + '/get-accounts',
      {
        upgradingAccountId,
        requisitionId,
      },
      {
        'X-ACTUAL-TOKEN': userToken,
      },
    );

    if (data) {
      if (data.error) {
        cb('unknown');
      } else {
        cb(null, data);
      }
    } else {
      setTimeout(() => getData(cb), 3000);
    }
  }

  return new Promise(resolve => {
    getData((error, data) => {
      if (error) {
        resolve({ error });
      } else {
        resolve({ data });
      }
    });
  });
};

handlers['gocardless-status'] = async function () {
  const userToken = await asyncStorage.getItem('user-token');

  if (!userToken) {
    return { error: 'unauthorized' };
  }

  return post(
    getServer().GOCARDLESS_SERVER + '/status',
    {},
    {
      'X-ACTUAL-TOKEN': userToken,
    },
  );
};

handlers['simplefin-status'] = async function () {
  const userToken = await asyncStorage.getItem('user-token');

  if (!userToken) {
    return { error: 'unauthorized' };
  }

  return post(
    getServer().SIMPLEFIN_SERVER + '/status',
    {},
    {
      'X-ACTUAL-TOKEN': userToken,
    },
  );
};

handlers['simplefin-accounts'] = async function () {
  const userToken = await asyncStorage.getItem('user-token');

  if (!userToken) {
    return { error: 'unauthorized' };
  }

  return post(
    getServer().SIMPLEFIN_SERVER + '/accounts',
    {},
    {
      'X-ACTUAL-TOKEN': userToken,
    },
  );
};

handlers['gocardless-get-banks'] = async function (country) {
  const userToken = await asyncStorage.getItem('user-token');

  if (!userToken) {
    return { error: 'unauthorized' };
  }

  return post(
    getServer().GOCARDLESS_SERVER + '/get-banks',
    { country, showDemo: isNonProductionEnvironment() },
    {
      'X-ACTUAL-TOKEN': userToken,
    },
  );
};

handlers['gocardless-poll-web-token-stop'] = async function () {
  stopPolling = true;
  return 'ok';
};

handlers['gocardless-create-web-token'] = async function ({
  upgradingAccountId,
  institutionId,
  accessValidForDays,
}) {
  const userToken = await asyncStorage.getItem('user-token');

  if (!userToken) {
    return { error: 'unauthorized' };
  }

  try {
    return await post(
      getServer().GOCARDLESS_SERVER + '/create-web-token',
      {
        upgradingAccountId,
        institutionId,
        accessValidForDays,
      },
      {
        'X-ACTUAL-TOKEN': userToken,
      },
    );
  } catch (error) {
    console.error(error);
    return { error: 'failed' };
  }
};

handlers['gocardless-accounts-sync'] = async function ({ id }) {
  const [[, userId], [, userKey]] = await asyncStorage.multiGet([
    'user-id',
    'user-key',
  ]);
  const accounts = await db.runQuery(
    `SELECT a.*, b.bank_id as bankId FROM accounts a
         LEFT JOIN banks b ON a.bank = b.id
         WHERE a.tombstone = 0 AND a.closed = 0 AND a.id = ?`,
    [id],
    true,
  );

  const errors = [];
  let newTransactions = [];
  let matchedTransactions = [];
  let updatedAccounts = [];

  for (let i = 0; i < accounts.length; i++) {
    const acct = accounts[i];
    if (acct.bankId) {
      try {
        console.group('Bank Sync operation');
        const res = await bankSync.syncAccount(
          userId,
          userKey,
          acct.id,
          acct.account_id,
          acct.bankId,
        );
        console.groupEnd();

        const { added, updated } = res;

        newTransactions = newTransactions.concat(added);
        matchedTransactions = matchedTransactions.concat(updated);

        if (added.length > 0 || updated.length > 0) {
          updatedAccounts = updatedAccounts.concat(acct.id);
        }
      } catch (err) {
        if (err.type === 'BankSyncError') {
          errors.push({
            type: 'SyncError',
            accountId: acct.id,
            message: 'Failed syncing account “' + acct.name + '.”',
            category: err.category,
            code: err.code,
          });
        } else if (err instanceof PostError && err.reason !== 'internal') {
          errors.push({
            accountId: acct.id,
            message: `Account “${acct.name}” is not linked properly. Please link it again`,
          });
        } else {
          errors.push({
            accountId: acct.id,
            message:
              'There was an internal error. Please get in touch https://actualbudget.org/contact for support.',
            internal: err.stack,
          });

          err.message = 'Failed syncing account: ' + err.message;

          captureException(err);
        }
      }
    }
  }

  if (updatedAccounts.length > 0) {
    connection.send('sync-event', {
      type: 'success',
      tables: ['transactions'],
    });
  }

  return { errors, newTransactions, matchedTransactions, updatedAccounts };
};

handlers['transactions-import'] = mutator(function ({
  accountId,
  transactions,
}) {
  return withUndo(async () => {
    if (typeof accountId !== 'string') {
      throw APIError('transactions-import: accountId must be an id');
    }

    try {
      return await bankSync.reconcileTransactions(accountId, transactions);
    } catch (err) {
      if (err instanceof TransactionError) {
        return { errors: [{ message: err.message }], added: [], updated: [] };
      }

      throw err;
    }
  });
});

handlers['account-unlink'] = mutator(async function ({ id }) {
  const { bank: bankId } = await db.first(
    'SELECT bank FROM accounts WHERE id = ?',
    [id],
  );

  if (!bankId) {
    return 'ok';
  }

  const accRow = await db.first('SELECT * FROM accounts WHERE id = ?', [id]);

  const isGoCardless = accRow.account_sync_source === 'goCardless';

  await db.updateAccount({
    id,
    account_id: null,
    bank: null,
    balance_current: null,
    balance_available: null,
    balance_limit: null,
    account_sync_source: null,
  });

  if (isGoCardless === false) {
    return;
  }

  const { count } = await db.first(
    'SELECT COUNT(*) as count FROM accounts WHERE bank = ?',
    [bankId],
  );

  // No more accounts are associated with this bank. We can remove
  // it from GoCardless.
  const userToken = await asyncStorage.getItem('user-token');
  if (!userToken) {
    return 'ok';
  }

  if (count === 0) {
    const { bank_id: requisitionId } = await db.first(
      'SELECT bank_id FROM banks WHERE id = ?',
      [bankId],
    );
    try {
      await post(
        getServer().GOCARDLESS_SERVER + '/remove-account',
        {
          requisitionId,
        },
        {
          'X-ACTUAL-TOKEN': userToken,
        },
      );
    } catch (error) {
      console.log({ error });
    }
  }

  return 'ok';
});

handlers['save-global-prefs'] = async function (prefs) {
  if ('maxMonths' in prefs) {
    await asyncStorage.setItem('max-months', '' + prefs.maxMonths);
  }
  if ('autoUpdate' in prefs) {
    await asyncStorage.setItem('auto-update', '' + prefs.autoUpdate);
    process.parentPort.postMessage({
      type: 'shouldAutoUpdate',
      flag: prefs.autoUpdate,
    });
  }
  if ('documentDir' in prefs) {
    if (await fs.exists(prefs.documentDir)) {
      await asyncStorage.setItem('document-dir', prefs.documentDir);
    }
  }
  if ('floatingSidebar' in prefs) {
    await asyncStorage.setItem('floating-sidebar', '' + prefs.floatingSidebar);
  }
  if ('theme' in prefs) {
    await asyncStorage.setItem('theme', prefs.theme);
  }
  return 'ok';
};

handlers['load-global-prefs'] = async function () {
  const [
    [, floatingSidebar],
    [, maxMonths],
    [, autoUpdate],
    [, documentDir],
    [, encryptKey],
    [, theme],
  ] = await asyncStorage.multiGet([
    'floating-sidebar',
    'max-months',
    'auto-update',
    'document-dir',
    'encrypt-key',
    'theme',
  ]);
  return {
    floatingSidebar: floatingSidebar === 'true' ? true : false,
    maxMonths: stringToInteger(maxMonths || ''),
    autoUpdate: autoUpdate == null || autoUpdate === 'true' ? true : false,
    documentDir: documentDir || getDefaultDocumentDir(),
    keyId: encryptKey && JSON.parse(encryptKey).id,
    theme:
      theme === 'light' ||
      theme === 'dark' ||
      theme === 'auto' ||
      theme === 'development' ||
      theme === 'midnight'
        ? theme
        : 'auto',
  };
};
handlers['save-prefs'] = async function (prefsToSet) {
  const { cloudFileId } = prefs.getPrefs();

  // Need to sync the budget name on the server as well
  if (prefsToSet.budgetName && cloudFileId) {
    const userToken = await asyncStorage.getItem('user-token');

    await post(getServer().SYNC_SERVER + '/update-user-filename', {
      token: userToken,
      fileId: cloudFileId,
      name: prefsToSet.budgetName,
    });
  }

  await prefs.savePrefs(prefsToSet);
  return 'ok';
};

handlers['load-prefs'] = async function () {
  return prefs.getPrefs();
};

handlers['sync-reset'] = async function () {
  return await resetSync();
};

handlers['sync-repair'] = async function () {
  await repairSync();
};

// A user can only enable/change their key with the file loaded. This
// will change in the future: during onboarding the user should be
// able to enable encryption. (Imagine if they are importing data from
// another source, they should be able to encrypt first)
handlers['key-make'] = async function ({ password }) {
  if (!prefs.getPrefs()) {
    throw new Error('user-set-key must be called with file loaded');
  }

  const salt = encryption.randomBytes(32).toString('base64');
  const id = uuidv4();
  const key = await encryption.createKey({ id, password, salt });

  // Load the key
  await encryption.loadKey(key);

  // Make some test data to use if the key is valid or not
  const testContent = await makeTestMessage(key.getId());

  // Changing your key necessitates a sync reset as well. This will
  // clear all existing encrypted data from the server so you won't
  // have a mix of data encrypted with different keys.
  return await resetSync({
    key,
    salt,
    testContent: JSON.stringify({
      ...testContent,
      value: testContent.value.toString('base64'),
    }),
  });
};

// This can be called both while a file is already loaded or not. This
// will see if a key is valid and if so save it off.
handlers['key-test'] = async function ({ fileId, password }) {
  const userToken = await asyncStorage.getItem('user-token');

  if (fileId == null) {
    fileId = prefs.getPrefs().cloudFileId;
  }

  let res;
  try {
    res = await post(getServer().SYNC_SERVER + '/user-get-key', {
      token: userToken,
      fileId,
    });
  } catch (e) {
    console.log(e);
    return { error: { reason: 'network' } };
  }

  const { id, salt, test: originalTest } = res;

  let test = originalTest;
  if (test == null) {
    return { error: { reason: 'old-key-style' } };
  }

  test = JSON.parse(test);

  const key = await encryption.createKey({ id, password, salt });
  encryption.loadKey(key);

  try {
    await encryption.decrypt(Buffer.from(test.value, 'base64'), test.meta);
  } catch (e) {
    console.log(e);

    // Unload the key, it's invalid
    encryption.unloadKey(key);
    return { error: { reason: 'decrypt-failure' } };
  }

  // Persist key in async storage
  const keys = JSON.parse((await asyncStorage.getItem(`encrypt-keys`)) || '{}');
  keys[fileId] = key.serialize();
  await asyncStorage.setItem('encrypt-keys', JSON.stringify(keys));

  // Save the key id in prefs if the are loaded. If they aren't, we
  // are testing a key to download a file and when the file is
  // actually downloaded it will update the prefs with the latest key id
  if (prefs.getPrefs()) {
    await prefs.savePrefs({ encryptKeyId: key.getId() });
  }

  return {};
};

handlers['get-did-bootstrap'] = async function () {
  return Boolean(await asyncStorage.getItem('did-bootstrap'));
};

handlers['subscribe-needs-bootstrap'] = async function ({
  url,
}: { url? } = {}) {
  try {
    if (!getServer(url)) {
      return { bootstrapped: true, hasServer: false };
    }
  } catch (err) {
    return { error: 'get-server-failure' };
  }

  let res;
  try {
    res = await get(getServer(url).SIGNUP_SERVER + '/needs-bootstrap');
  } catch (err) {
    return { error: 'network-failure' };
  }

  try {
    res = JSON.parse(res);
  } catch (err) {
    return { error: 'parse-failure' };
  }

  if (res.status === 'error') {
    return { error: res.reason };
  }

  return { bootstrapped: res.data.bootstrapped, hasServer: true };
};

handlers['subscribe-bootstrap'] = async function ({ password }) {
  let res;
  try {
    res = await post(getServer().SIGNUP_SERVER + '/bootstrap', { password });
  } catch (err) {
    return { error: err.reason || 'network-failure' };
  }

  if (res.token) {
    await asyncStorage.setItem('user-token', res.token);
    return {};
  }
  return { error: 'internal' };
};

handlers['subscribe-get-user'] = async function () {
  if (!getServer()) {
    if (!(await asyncStorage.getItem('did-bootstrap'))) {
      return null;
    }
    return { offline: false };
  }

  const userToken = await asyncStorage.getItem('user-token');

  if (!userToken) {
    return null;
  }

  try {
    const res = await get(getServer().SIGNUP_SERVER + '/validate', {
      headers: {
        'X-ACTUAL-TOKEN': userToken,
      },
    });
    const { status, reason } = JSON.parse(res);

    if (status === 'error') {
      if (reason === 'unauthorized') {
        return null;
      }
      return { offline: true };
    }

    return { offline: false };
  } catch (e) {
    console.log(e);
    return { offline: true };
  }
};

handlers['subscribe-change-password'] = async function ({ password }) {
  const userToken = await asyncStorage.getItem('user-token');
  if (!userToken) {
    return { error: 'not-logged-in' };
  }

  try {
    await post(getServer().SIGNUP_SERVER + '/change-password', {
      token: userToken,
      password,
    });
  } catch (err) {
    return { error: err.reason || 'network-failure' };
  }

  return {};
};

handlers['subscribe-sign-in'] = async function ({ password }) {
  const res = await post(getServer().SIGNUP_SERVER + '/login', {
    password,
  });

  if (res.token) {
    await asyncStorage.setItem('user-token', res.token);
    return {};
  }

  return { error: 'invalid-password' };
};

handlers['subscribe-sign-out'] = async function () {
  encryption.unloadAllKeys();
  await asyncStorage.multiRemove([
    'user-token',
    'encrypt-keys',
    'lastBudget',
    'readOnly',
  ]);
  return 'ok';
};

handlers['get-server-version'] = async function () {
  if (!getServer()) {
    return { error: 'no-server' };
  }

  let version;
  try {
    const res = await get(getServer().BASE_SERVER + '/info');

    const info = JSON.parse(res);
    version = info.build.version;
  } catch (err) {
    return { error: 'network-failure' };
  }

  return { version };
};

handlers['get-server-url'] = async function () {
  return getServer() && getServer().BASE_SERVER;
};

handlers['set-server-url'] = async function ({ url, validate = true }) {
  if (url == null) {
    await asyncStorage.removeItem('user-token');
  } else {
    url = url.replace(/\/+$/, '');

    if (validate) {
      // Validate the server is running
      const result = await runHandler(handlers['subscribe-needs-bootstrap'], {
        url,
      });
      if ('error' in result) {
        return { error: result.error };
      }
    }
  }

  await asyncStorage.setItem('server-url', url);
  await asyncStorage.setItem('did-bootstrap', true);
  setServer(url);
  return {};
};

handlers['sync'] = async function () {
  return fullSync();
};

handlers['get-budgets'] = async function () {
  const paths = await fs.listDir(fs.getDocumentDir());
  const budgets = (
    await Promise.all(
      paths.map(async name => {
        const prefsPath = fs.join(fs.getDocumentDir(), name, 'metadata.json');
        if (await fs.exists(prefsPath)) {
          let prefs;
          try {
            prefs = JSON.parse(await fs.readFile(prefsPath));
          } catch (e) {
            console.log('Error parsing metadata:', e.stack);
            return;
          }

          // We treat the directory name as the canonical id so that if
          // the user moves it around/renames/etc, nothing breaks. The
          // id is stored in prefs just for convenience (and the prefs
          // will always update to the latest given id)
          if (name !== DEMO_BUDGET_ID) {
            return {
              id: name,
              cloudFileId: prefs.cloudFileId,
              groupId: prefs.groupId,
              name: prefs.budgetName || '(no name)',
            };
          }
        }

        return null;
      }),
    )
  ).filter(x => x);

  return budgets;
};

handlers['get-remote-files'] = async function () {
  return cloudStorage.listRemoteFiles();
};

handlers['reset-budget-cache'] = mutator(async function () {
  // Recomputing everything will update the cache
  await sheet.loadUserBudgets(db);
  sheet.get().recomputeAll();
  await sheet.waitOnSpreadsheet();
});

handlers['upload-budget'] = async function ({ id }: { id? } = {}) {
  if (id) {
    if (prefs.getPrefs()) {
      throw new Error('upload-budget: id given but prefs already loaded');
    }
    await prefs.loadPrefs(id);
  }

  try {
    await cloudStorage.upload();
  } catch (e) {
    console.log(e);
    if (e.type === 'FileUploadError') {
      return { error: e };
    }
    captureException(e);
    return { error: { reason: 'internal' } };
  } finally {
    if (id) {
      prefs.unloadPrefs();
    }
  }

  return {};
};

handlers['download-budget'] = async function ({ fileId }) {
  let result;
  try {
    result = await cloudStorage.download(fileId);
  } catch (e) {
    if (e.type === 'FileDownloadError') {
      if (e.reason === 'file-exists' && e.meta.id) {
        await prefs.loadPrefs(e.meta.id);
        const name = prefs.getPrefs().budgetName;
        prefs.unloadPrefs();

        e.meta = { ...e.meta, name };
      }

      return { error: e };
    } else {
      captureException(e);
      return { error: { reason: 'internal' } };
    }
  }

  const id = result.id;
  await handlers['load-budget']({ id });
  result = await handlers['sync-budget']();
  await handlers['close-budget']();
  if (result.error) {
    return result;
  }
  return { id };
};

// open and sync, but don’t close
handlers['sync-budget'] = async function () {
  setSyncingMode('enabled');
  const result = await initialFullSync();

  return result;
};

handlers['load-budget'] = async function ({ id }) {
  const currentPrefs = prefs.getPrefs();

  if (currentPrefs) {
    if (currentPrefs.id === id) {
      // If it's already loaded, do nothing
      return {};
    } else {
      // Otherwise, close the currently loaded budget
      await handlers['close-budget']();
    }
  }

  const res = await loadBudget(id);

  return res;
};

handlers['create-demo-budget'] = async function () {
  // Make sure the read only flag isn't leftover (normally it's
  // reset when signing in, but you don't have to sign in for the
  // demo budget)
  await asyncStorage.setItem('readOnly', '');

  return handlers['create-budget']({
    budgetName: 'Demo Budget',
    testMode: true,
    testBudgetId: DEMO_BUDGET_ID,
  });
};

handlers['close-budget'] = async function () {
  captureBreadcrumb({ message: 'Closing budget' });

  // The spreadsheet may be running, wait for it to complete
  await sheet.waitOnSpreadsheet();
  sheet.unloadSpreadsheet();

  clearFullSyncTimeout();
  await app.stopServices();

  await db.closeDatabase();

  try {
    await asyncStorage.setItem('lastBudget', '');
  } catch (e) {
    // This might fail if we are shutting down after failing to load a
    // budget. We want to unload whatever has already been loaded but
    // be resilient to anything failing
  }

  prefs.unloadPrefs();
  stopBackupService();
  return 'ok';
};

handlers['delete-budget'] = async function ({ id, cloudFileId }) {
  // If it's a cloud file, you can delete it from the server by
  // passing its cloud id
  if (cloudFileId) {
    await cloudStorage.removeFile(cloudFileId).catch(() => {});
  }

  // If a local file exists, you can delete it by passing its local id
  if (id) {
    const budgetDir = fs.getBudgetDir(id);
    await fs.removeDirRecursively(budgetDir);
  }

  return 'ok';
};

handlers['create-budget'] = async function ({
  budgetName,
  avoidUpload,
  testMode,
  testBudgetId,
}: {
  budgetName?;
  avoidUpload?;
  testMode?;
  testBudgetId?;
} = {}) {
  let id;
  if (testMode) {
    budgetName = budgetName || 'Test Budget';
    id = testBudgetId || TEST_BUDGET_ID;

    if (await fs.exists(fs.getBudgetDir(id))) {
      await fs.removeDirRecursively(fs.getBudgetDir(id));
    }
  } else {
    // Generate budget name if not given
    if (!budgetName) {
      // Unfortunately we need to load all of the existing files first
      // so we can detect conflicting names.
      const files = await handlers['get-budgets']();
      budgetName = await uniqueFileName(files);
    }

    id = await idFromFileName(budgetName);
  }

  const budgetDir = fs.getBudgetDir(id);
  await fs.mkdir(budgetDir);

  // Create the initial database
  await fs.copyFile(fs.bundledDatabasePath, fs.join(budgetDir, 'db.sqlite'));

  // Create the initial prefs file
  await fs.writeFile(
    fs.join(budgetDir, 'metadata.json'),
    JSON.stringify(prefs.getDefaultPrefs(id, budgetName)),
  );

  // Load it in
  const { error } = await loadBudget(id);
  if (error) {
    console.log('Error creating budget: ' + error);
    return { error };
  }

  if (!avoidUpload && !testMode) {
    try {
      await cloudStorage.upload();
    } catch (e) {
      // Ignore any errors uploading. If they are offline they should
      // still be able to create files.
    }
  }

  if (testMode) {
    await createTestBudget(handlers);
  }

  return {};
};

handlers['import-budget'] = async function ({ filepath, type }) {
  try {
    if (!(await fs.exists(filepath))) {
      throw new Error(`File not found at the provided path: ${filepath}`);
    }

    const buffer = Buffer.from(await fs.readFile(filepath, 'binary'));
    const results = await handleBudgetImport(type, filepath, buffer);
    return results || {};
  } catch (err) {
    err.message = 'Error importing budget: ' + err.message;
    captureException(err);
    return { error: 'internal-error' };
  }
};

handlers['export-budget'] = async function () {
  try {
    return {
      data: await cloudStorage.exportBuffer(),
    };
  } catch (err) {
    err.message = 'Error exporting budget: ' + err.message;
    captureException(err);
    return { error: 'internal-error' };
  }
};

async function loadBudget(id) {
  let dir;
  try {
    dir = fs.getBudgetDir(id);
  } catch (e) {
    captureException(
      new Error('`getBudgetDir` failed in `loadBudget`: ' + e.message),
    );
    return { error: 'budget-not-found' };
  }

  captureBreadcrumb({ message: 'Loading budget ' + dir });

  if (!(await fs.exists(dir))) {
    captureException(new Error('budget directory does not exist'));
    return { error: 'budget-not-found' };
  }

  try {
    await prefs.loadPrefs(id);
    await db.openDatabase(id);
  } catch (e) {
    captureBreadcrumb({ message: 'Error loading budget ' + id });
    captureException(e);
    await handlers['close-budget']();
    return { error: 'opening-budget' };
  }

  // Older versions didn't tag the file with the current user, so do
  // so now
  if (!prefs.getPrefs().userId) {
    const userId = await asyncStorage.getItem('user-token');
    prefs.savePrefs({ userId });
  }

  try {
    await updateVersion();
  } catch (e) {
    console.warn('Error updating', e);
    let result;
    if (e.message.includes('out-of-sync-migrations')) {
      result = { error: 'out-of-sync-migrations' };
    } else if (e.message.includes('out-of-sync-data')) {
      result = { error: 'out-of-sync-data' };
    } else {
      captureException(e);
      logger.info('Error updating budget ' + id, e);
      console.log('Error updating budget', e);
      result = { error: 'loading-budget' };
    }

    await handlers['close-budget']();
    return result;
  }

  await db.loadClock();

  if (prefs.getPrefs().resetClock) {
    // If we need to generate a fresh clock, we need to generate a new
    // client id. This happens when the database is transferred to a
    // new device.
    //
    // TODO: The client id should be stored elsewhere. It shouldn't
    // work this way, but it's fine for now.
    CRDT.getClock().timestamp.setNode(CRDT.makeClientId());
    await db.runQuery(
      'INSERT OR REPLACE INTO messages_clock (id, clock) VALUES (1, ?)',
      [CRDT.serializeClock(CRDT.getClock())],
    );

    await prefs.savePrefs({ resetClock: false });
  }

  if (
    !Platform.isWeb &&
    !Platform.isMobile &&
    process.env.NODE_ENV !== 'test'
  ) {
    startBackupService(id);
  }

  try {
    await sheet.loadSpreadsheet(db, onSheetChange);
  } catch (e) {
    captureException(e);
    await handlers['close-budget']();
    return { error: 'opening-budget' };
  }

  // This is a bit leaky, but we need to set the initial budget type
  sheet.get().meta().budgetType = prefs.getPrefs().budgetType;
  await budget.createAllBudgets();

  // Load all the in-memory state
  await mappings.loadMappings();
  await rules.loadRules();
  await syncMigrations.listen();
  await app.startServices();

  clearUndo();

  // Ensure that syncing is enabled
  if (process.env.NODE_ENV !== 'test') {
    if (id === DEMO_BUDGET_ID) {
      setSyncingMode('disabled');
    } else {
      if (getServer()) {
        setSyncingMode('enabled');
      } else {
        setSyncingMode('disabled');
      }

      await asyncStorage.setItem('lastBudget', id);

      // Only upload periodically on desktop
      if (!Platform.isMobile) {
        await cloudStorage.possiblyUpload();
      }
    }
  }

  app.events.emit('load-budget', { id });

  return {};
}

handlers['upload-file-web'] = async function ({ filename, contents }) {
  if (!Platform.isWeb) {
    return null;
  }

  await fs.writeFile('/uploads/' + filename, contents);
  return {};
};

handlers['backups-get'] = async function ({ id }) {
  return getAvailableBackups(id);
};

handlers['backup-load'] = async function ({ id, backupId }) {
  await loadBackup(id, backupId);
};

handlers['backup-make'] = async function ({ id }) {
  await makeBackup(id);
};

handlers['get-last-opened-backup'] = async function () {
  const id = await asyncStorage.getItem('lastBudget');
  if (id && id !== '') {
    const budgetDir = fs.getBudgetDir(id);

    // We never want to give back a budget that does not exist on the
    // filesystem anymore, so first check that it exists
    if (await fs.exists(budgetDir)) {
      return id;
    }
  }
  return null;
};

handlers['app-focused'] = async function () {
  if (prefs.getPrefs() && prefs.getPrefs().id) {
    // First we sync
    fullSync();
  }
};

handlers = installAPI(handlers) as Handlers;

injectAPI.override((name, args) => runHandler(app.handlers[name], args));

// A hack for now until we clean up everything
app.handlers = handlers;
app.combine(
  schedulesApp,
  budgetApp,
  notesApp,
  toolsApp,
  filtersApp,
  reportsApp,
  rulesApp,
);

function getDefaultDocumentDir() {
  if (Platform.isMobile) {
    // On mobile, unfortunately we need to be backwards compatible
    // with the old folder structure which does not store files inside
    // of an `Actual` directory. In the future, if we really care, we
    // can migrate them, but for now just return the documents dir
    return process.env.ACTUAL_DOCUMENT_DIR;
  }
  return fs.join(process.env.ACTUAL_DOCUMENT_DIR, 'Actual');
}

async function setupDocumentsDir() {
  async function ensureExists(dir) {
    // Make sure the document folder exists
    if (!(await fs.exists(dir))) {
      await fs.mkdir(dir);
    }
  }

  let documentDir = await asyncStorage.getItem('document-dir');

  // Test the existing documents directory to make sure it's a valid
  // path that exists, and if it errors fallback to the default one
  if (documentDir) {
    try {
      await ensureExists(documentDir);
    } catch (e) {
      documentDir = null;
    }
  }

  if (!documentDir) {
    documentDir = getDefaultDocumentDir();
  }

  await ensureExists(documentDir);
  fs._setDocumentDir(documentDir);
}

// eslint-disable-next-line import/no-unused-modules
export async function initApp(isDev, socketName) {
  await sqlite.init();
  await Promise.all([asyncStorage.init(), fs.init()]);
  await setupDocumentsDir();

  const keysStr = await asyncStorage.getItem('encrypt-keys');
  if (keysStr) {
    try {
      const keys = JSON.parse(keysStr);

      // Load all the keys
      await Promise.all(
        Object.keys(keys).map(fileId => {
          return encryption.loadKey(keys[fileId]);
        }),
      );
    } catch (e) {
      console.log('Error loading key', e);
      throw new Error('load-key-error');
    }
  }

  const url = await asyncStorage.getItem('server-url');

  if (!url) {
    await asyncStorage.removeItem('user-token');
  }
  setServer(url);

  connection.init(socketName, app.handlers);

  if (!isDev && !Platform.isMobile && !Platform.isWeb) {
    const autoUpdate = await asyncStorage.getItem('auto-update');
    process.parentPort.postMessage({
      type: 'shouldAutoUpdate',
      flag: autoUpdate == null || autoUpdate === 'true',
    });
  }

  // Allow running DB queries locally
  global.$query = aqlQuery;
  global.$q = q;

  if (isDev) {
    global.$send = (name, args) => runHandler(app.handlers[name], args);
    global.$db = db;
    global.$setSyncingMode = setSyncingMode;
  }
}

export type InitConfig = {
  dataDir?: string;
  serverURL?: string;
  password?: string;
};

// eslint-disable-next-line import/no-unused-modules
export async function init(config: InitConfig) {
  // Get from build

  let dataDir, serverURL;
  if (config) {
    dataDir = config.dataDir;
    serverURL = config.serverURL;
  } else {
    dataDir = process.env.ACTUAL_DATA_DIR;
    serverURL = process.env.ACTUAL_SERVER_URL;
  }

  await sqlite.init();
  await Promise.all([asyncStorage.init({ persist: false }), fs.init()]);
  fs._setDocumentDir(dataDir || process.cwd());

  if (serverURL) {
    setServer(serverURL);

    if (config.password) {
      await runHandler(handlers['subscribe-sign-in'], {
        password: config.password,
      });
    }
  } else {
    // This turns off all server URLs. In this mode we don't want any
    // access to the server, we are doing things locally
    setServer(null);

    app.events.on('load-budget', () => {
      setSyncingMode('offline');
    });
  }

  return lib;
}

// Export a few things required for the platform
// eslint-disable-next-line import/no-unused-modules
export const lib = {
  getDataDir: fs.getDataDir,
  sendMessage: (msg, args) => connection.send(msg, args),
  send: async <K extends keyof Handlers, T extends Handlers[K]>(
    name: K,
    args?: Parameters<T>[0],
  ): Promise<Awaited<ReturnType<T>>> => {
    const res = await runHandler(app.handlers[name], args);
    return res;
  },
  on: (name, func) => app.events.on(name, func),
  q,
  db,
};