mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-30 03:23:51 -05:00
1458 lines
36 KiB
TypeScript
1458 lines
36 KiB
TypeScript
import { t } from 'i18next';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
|
|
import { captureException } from '../../platform/exceptions';
|
|
import * as asyncStorage from '../../platform/server/asyncStorage';
|
|
import * as connection from '../../platform/server/connection';
|
|
import { logger } from '../../platform/server/log';
|
|
import { isNonProductionEnvironment } from '../../shared/environment';
|
|
import { dayFromDate } from '../../shared/months';
|
|
import * as monthUtils from '../../shared/months';
|
|
import { amountToInteger } from '../../shared/util';
|
|
import {
|
|
AccountEntity,
|
|
CategoryEntity,
|
|
SyncServerGoCardlessAccount,
|
|
TransactionEntity,
|
|
SyncServerSimpleFinAccount,
|
|
SyncServerPluggyAiAccount,
|
|
type GoCardlessToken,
|
|
ImportTransactionEntity,
|
|
} from '../../types/models';
|
|
import { createApp } from '../app';
|
|
import * as db from '../db';
|
|
import {
|
|
APIError,
|
|
BankSyncError,
|
|
PostError,
|
|
TransactionError,
|
|
} from '../errors';
|
|
import { app as mainApp } from '../main-app';
|
|
import { mutator } from '../mutators';
|
|
import { get, post } from '../post';
|
|
import { getServer } from '../server-config';
|
|
import { batchMessages } from '../sync';
|
|
import { undoable, withUndo } from '../undo';
|
|
|
|
import * as link from './link';
|
|
import { getStartingBalancePayee } from './payees';
|
|
import * as bankSync from './sync';
|
|
|
|
export type AccountHandlers = {
|
|
'account-update': typeof updateAccount;
|
|
'accounts-get': typeof getAccounts;
|
|
'account-balance': typeof getAccountBalance;
|
|
'account-properties': typeof getAccountProperties;
|
|
'gocardless-accounts-link': typeof linkGoCardlessAccount;
|
|
'simplefin-accounts-link': typeof linkSimpleFinAccount;
|
|
'pluggyai-accounts-link': typeof linkPluggyAiAccount;
|
|
'account-create': typeof createAccount;
|
|
'account-close': typeof closeAccount;
|
|
'account-reopen': typeof reopenAccount;
|
|
'account-move': typeof moveAccount;
|
|
'secret-set': typeof setSecret;
|
|
'secret-check': typeof checkSecret;
|
|
'gocardless-poll-web-token': typeof pollGoCardlessWebToken;
|
|
'gocardless-poll-web-token-stop': typeof stopGoCardlessWebTokenPolling;
|
|
'gocardless-status': typeof goCardlessStatus;
|
|
'simplefin-status': typeof simpleFinStatus;
|
|
'pluggyai-status': typeof pluggyAiStatus;
|
|
'simplefin-accounts': typeof simpleFinAccounts;
|
|
'pluggyai-accounts': typeof pluggyAiAccounts;
|
|
'gocardless-get-banks': typeof getGoCardlessBanks;
|
|
'gocardless-create-web-token': typeof createGoCardlessWebToken;
|
|
'accounts-bank-sync': typeof accountsBankSync;
|
|
'simplefin-batch-sync': typeof simpleFinBatchSync;
|
|
'bank-sync-providers-list': typeof getPluginProviders;
|
|
'bank-sync-status': typeof getPluginStatus;
|
|
'bank-sync-accounts': typeof getPluginAccounts;
|
|
'bank-sync-accounts-link': typeof linkPluginAccount;
|
|
'transactions-import': typeof importTransactions;
|
|
'account-unlink': typeof unlinkAccount;
|
|
};
|
|
|
|
async function updateAccount({
|
|
id,
|
|
name,
|
|
last_reconciled,
|
|
}: Pick<AccountEntity, 'id' | 'name'> &
|
|
Partial<Pick<AccountEntity, 'last_reconciled'>>) {
|
|
await db.update('accounts', {
|
|
id,
|
|
name,
|
|
...(last_reconciled && { last_reconciled }),
|
|
});
|
|
return {};
|
|
}
|
|
|
|
async function getAccounts() {
|
|
return db.getAccounts();
|
|
}
|
|
|
|
async function getAccountBalance({
|
|
id,
|
|
cutoff,
|
|
}: {
|
|
id: string;
|
|
cutoff: string | Date;
|
|
}) {
|
|
const result = await db.first<{ balance: number }>(
|
|
'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0 AND date <= ?',
|
|
[id, db.toDateRepr(dayFromDate(cutoff))],
|
|
);
|
|
return result?.balance ? result.balance : 0;
|
|
}
|
|
|
|
async function getAccountProperties({ id }: { id: AccountEntity['id'] }) {
|
|
const balanceResult = await db.first<{ balance: number }>(
|
|
'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0',
|
|
[id],
|
|
);
|
|
const countResult = await db.first<{ count: number }>(
|
|
'SELECT count(id) as count FROM transactions WHERE acct = ? AND tombstone = 0',
|
|
[id],
|
|
);
|
|
|
|
return {
|
|
balance: balanceResult?.balance || 0,
|
|
numTransactions: countResult?.count || 0,
|
|
};
|
|
}
|
|
|
|
async function linkGoCardlessAccount({
|
|
requisitionId,
|
|
account,
|
|
upgradingId,
|
|
offBudget = false,
|
|
}: {
|
|
requisitionId: string;
|
|
account: SyncServerGoCardlessAccount;
|
|
upgradingId?: AccountEntity['id'] | undefined;
|
|
offBudget?: boolean | undefined;
|
|
}) {
|
|
let id;
|
|
const bank = await link.findOrCreateBank(account.institution, requisitionId);
|
|
|
|
if (upgradingId) {
|
|
const accRow = await db.first<db.DbAccount>(
|
|
'SELECT * FROM accounts WHERE id = ?',
|
|
[upgradingId],
|
|
);
|
|
|
|
if (!accRow) {
|
|
throw new Error(`Account with ID ${upgradingId} not found.`);
|
|
}
|
|
|
|
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,
|
|
offbudget: offBudget ? 1 : 0,
|
|
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';
|
|
}
|
|
|
|
async function linkSimpleFinAccount({
|
|
externalAccount,
|
|
upgradingId,
|
|
offBudget = false,
|
|
}: {
|
|
externalAccount: SyncServerSimpleFinAccount;
|
|
upgradingId?: AccountEntity['id'] | undefined;
|
|
offBudget?: boolean | undefined;
|
|
}) {
|
|
let id;
|
|
|
|
const institution = {
|
|
name: externalAccount.institution ?? t('Unknown'),
|
|
};
|
|
|
|
const bank = await link.findOrCreateBank(
|
|
institution,
|
|
externalAccount.orgDomain ?? externalAccount.orgId,
|
|
);
|
|
|
|
if (upgradingId) {
|
|
const accRow = await db.first<db.DbAccount>(
|
|
'SELECT * FROM accounts WHERE id = ?',
|
|
[upgradingId],
|
|
);
|
|
|
|
if (!accRow) {
|
|
throw new Error(`Account with ID ${upgradingId} not found.`);
|
|
}
|
|
|
|
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,
|
|
offbudget: offBudget ? 1 : 0,
|
|
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';
|
|
}
|
|
|
|
async function linkPluggyAiAccount({
|
|
externalAccount,
|
|
upgradingId,
|
|
offBudget = false,
|
|
}: {
|
|
externalAccount: SyncServerPluggyAiAccount;
|
|
upgradingId?: AccountEntity['id'] | undefined;
|
|
offBudget?: boolean | undefined;
|
|
}) {
|
|
let id;
|
|
|
|
const institution = {
|
|
name: externalAccount.institution ?? t('Unknown'),
|
|
};
|
|
|
|
const bank = await link.findOrCreateBank(
|
|
institution,
|
|
externalAccount.orgDomain ?? externalAccount.orgId,
|
|
);
|
|
|
|
if (upgradingId) {
|
|
const accRow = await db.first<db.DbAccount>(
|
|
'SELECT * FROM accounts WHERE id = ?',
|
|
[upgradingId],
|
|
);
|
|
|
|
if (!accRow) {
|
|
throw new Error(`Account with ID ${upgradingId} not found.`);
|
|
}
|
|
|
|
id = accRow.id;
|
|
await db.update('accounts', {
|
|
id,
|
|
account_id: externalAccount.account_id,
|
|
bank: bank.id,
|
|
account_sync_source: 'pluggyai',
|
|
});
|
|
} else {
|
|
id = uuidv4();
|
|
await db.insertWithUUID('accounts', {
|
|
id,
|
|
account_id: externalAccount.account_id,
|
|
name: externalAccount.name,
|
|
official_name: externalAccount.name,
|
|
bank: bank.id,
|
|
offbudget: offBudget ? 1 : 0,
|
|
account_sync_source: 'pluggyai',
|
|
});
|
|
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';
|
|
}
|
|
|
|
async function linkPluginAccount({
|
|
providerSlug,
|
|
externalAccount,
|
|
upgradingId,
|
|
offBudget = false,
|
|
}: {
|
|
providerSlug: string;
|
|
externalAccount: {
|
|
account_id: string;
|
|
name: string;
|
|
institution: string;
|
|
balance: number;
|
|
[key: string]: string | number;
|
|
};
|
|
upgradingId?: AccountEntity['id'];
|
|
offBudget?: boolean;
|
|
}) {
|
|
let id;
|
|
// For plugin accounts, we'll use a generic bank entry or create one based on the provider
|
|
const providerName =
|
|
typeof externalAccount.institution === 'string'
|
|
? externalAccount.institution
|
|
: (externalAccount.institution as any)?.name || providerSlug;
|
|
|
|
const bank = await link.findOrCreateBank(
|
|
{ name: providerName },
|
|
providerSlug, // Use providerSlug as the bank identifier
|
|
);
|
|
|
|
if (upgradingId) {
|
|
const accRow = await db.first<db.DbAccount>(
|
|
'SELECT * FROM accounts WHERE id = ?',
|
|
[upgradingId],
|
|
);
|
|
|
|
if (!accRow) {
|
|
throw new Error(`Account with ID ${upgradingId} not found.`);
|
|
}
|
|
|
|
id = accRow.id;
|
|
await db.update('accounts', {
|
|
id,
|
|
account_id: externalAccount.account_id,
|
|
bank: bank.id,
|
|
account_sync_source: providerSlug,
|
|
});
|
|
} else {
|
|
id = uuidv4();
|
|
await db.insertWithUUID('accounts', {
|
|
id,
|
|
account_id: externalAccount.account_id,
|
|
name: externalAccount.name,
|
|
official_name: externalAccount.name,
|
|
bank: bank.id,
|
|
offbudget: offBudget ? 1 : 0,
|
|
account_sync_source: providerSlug,
|
|
});
|
|
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';
|
|
}
|
|
|
|
async function getPluginAccounts({
|
|
providerSlug,
|
|
credentials,
|
|
}: {
|
|
providerSlug: string;
|
|
credentials?: Record<string, string>;
|
|
}) {
|
|
const server = getServer();
|
|
if (!server) {
|
|
throw new Error('No server configured');
|
|
}
|
|
|
|
try {
|
|
// Call the plugin's accounts endpoint
|
|
const pluginUrl = `${server.BASE_SERVER}/plugins-api/bank-sync/${providerSlug}/accounts`;
|
|
|
|
// Get user token for authentication
|
|
const userToken = await asyncStorage.getItem('user-token');
|
|
if (!userToken) {
|
|
throw new Error('User not authenticated');
|
|
}
|
|
|
|
const response = await post(
|
|
pluginUrl,
|
|
credentials,
|
|
{
|
|
'X-ACTUAL-TOKEN': userToken,
|
|
},
|
|
null,
|
|
{
|
|
redirect: 'follow',
|
|
},
|
|
);
|
|
|
|
if (!('error' in response)) {
|
|
return response;
|
|
} else {
|
|
throw new Error(response.error || 'Plugin error');
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error fetching plugin accounts:', error);
|
|
throw new Error(String(error) || 'Failed to fetch plugin accounts');
|
|
}
|
|
}
|
|
|
|
async function getPluginProviders() {
|
|
const server = getServer();
|
|
if (!server) {
|
|
throw new Error('No server configured');
|
|
}
|
|
|
|
try {
|
|
// Call the plugin system's bank sync list endpoint
|
|
const pluginUrl = `${server.BASE_SERVER}/plugins-api/bank-sync/list`;
|
|
|
|
// Get user token for authentication
|
|
const userToken = await asyncStorage.getItem('user-token');
|
|
if (!userToken) {
|
|
throw new Error('User not authenticated');
|
|
}
|
|
|
|
const response = await get(pluginUrl, {
|
|
headers: { 'X-ACTUAL-TOKEN': userToken },
|
|
});
|
|
|
|
const data = JSON.parse(response);
|
|
|
|
if (data.status === 'ok') {
|
|
return {
|
|
providers: data.data.providers || [],
|
|
};
|
|
} else {
|
|
throw new Error(data.error || 'Plugin error');
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error fetching plugin providers:', error);
|
|
throw new Error(String(error) || 'Failed to fetch plugin providers');
|
|
}
|
|
}
|
|
|
|
export { getPluginProviders };
|
|
|
|
async function getPluginStatus({ providerSlug }: { providerSlug: string }) {
|
|
const server = getServer();
|
|
if (!server) {
|
|
throw new Error('No server configured');
|
|
}
|
|
|
|
try {
|
|
// Call the plugin's status endpoint
|
|
const pluginUrl = `${server.BASE_SERVER}/plugins-api/bank-sync/${providerSlug}/status`;
|
|
|
|
// Get user token for authentication
|
|
const userToken = await asyncStorage.getItem('user-token');
|
|
if (!userToken) {
|
|
throw new Error('User not authenticated');
|
|
}
|
|
|
|
const response = await get(pluginUrl, {
|
|
headers: { 'X-ACTUAL-TOKEN': userToken },
|
|
redirect: 'follow',
|
|
});
|
|
|
|
const data = JSON.parse(response);
|
|
|
|
if (data.status === 'ok') {
|
|
return {
|
|
configured: data.data?.configured || false,
|
|
error: data.data?.error,
|
|
};
|
|
} else {
|
|
return {
|
|
configured: false,
|
|
error: data.error || 'Plugin error',
|
|
};
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Error checking status for plugin ${providerSlug}:`, error);
|
|
return {
|
|
configured: false,
|
|
error: String(error),
|
|
};
|
|
}
|
|
}
|
|
|
|
async function createAccount({
|
|
name,
|
|
balance = 0,
|
|
offBudget = false,
|
|
closed = false,
|
|
}: {
|
|
name: string;
|
|
balance?: number | undefined;
|
|
offBudget?: boolean | undefined;
|
|
closed?: boolean | undefined;
|
|
}) {
|
|
const id: AccountEntity['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;
|
|
}
|
|
|
|
async function closeAccount({
|
|
id,
|
|
transferAccountId,
|
|
categoryId,
|
|
forced = false,
|
|
}: {
|
|
id: AccountEntity['id'];
|
|
transferAccountId?: AccountEntity['id'] | undefined;
|
|
categoryId?: CategoryEntity['id'] | undefined;
|
|
forced?: boolean | undefined;
|
|
}) {
|
|
// 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 unlinkAccount({ id });
|
|
|
|
return withUndo(async () => {
|
|
const account = await db.first<db.DbAccount>(
|
|
'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 getAccountProperties({ 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<
|
|
Pick<db.DbViewTransaction, 'id' | 'transfer_id'>
|
|
>(
|
|
'SELECT id, transfer_id FROM v_transactions WHERE account = ?',
|
|
[id],
|
|
true,
|
|
);
|
|
|
|
const transferPayee = await db.first<Pick<db.DbPayee, 'id'>>(
|
|
'SELECT id FROM payees WHERE transfer_acct = ?',
|
|
[id],
|
|
);
|
|
|
|
if (!transferPayee) {
|
|
throw new Error(`Transfer payee with account ID ${id} not found.`);
|
|
}
|
|
|
|
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: transferPayee.id });
|
|
});
|
|
} else {
|
|
if (balance !== 0 && transferAccountId == null) {
|
|
throw APIError('balance is non-zero: transferAccountId is required');
|
|
}
|
|
|
|
if (id === transferAccountId) {
|
|
throw APIError('transfer account can not be the account being closed');
|
|
}
|
|
|
|
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 && transferAccountId) {
|
|
const transferPayee = await db.first<Pick<db.DbPayee, 'id'>>(
|
|
'SELECT id FROM payees WHERE transfer_acct = ?',
|
|
[transferAccountId],
|
|
);
|
|
|
|
if (!transferPayee) {
|
|
throw new Error(
|
|
`Transfer payee with account ID ${transferAccountId} not found.`,
|
|
);
|
|
}
|
|
|
|
await mainApp.handlers['transaction-add']({
|
|
id: uuidv4(),
|
|
payee: transferPayee.id,
|
|
amount: -balance,
|
|
account: id,
|
|
date: monthUtils.currentDay(),
|
|
notes: 'Closing account',
|
|
category: categoryId,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function reopenAccount({ id }: { id: AccountEntity['id'] }) {
|
|
await db.update('accounts', { id, closed: 0 });
|
|
}
|
|
|
|
async function moveAccount({
|
|
id,
|
|
targetId,
|
|
}: {
|
|
id: AccountEntity['id'];
|
|
targetId: AccountEntity['id'] | null;
|
|
}) {
|
|
await db.moveAccount(id, targetId);
|
|
}
|
|
|
|
async function setSecret({
|
|
name,
|
|
value,
|
|
}: {
|
|
name: string;
|
|
value: string | null;
|
|
}) {
|
|
const userToken = await asyncStorage.getItem('user-token');
|
|
|
|
if (!userToken) {
|
|
return { error: 'unauthorized' };
|
|
}
|
|
|
|
const serverConfig = getServer();
|
|
if (!serverConfig) {
|
|
throw new Error('Failed to get server config.');
|
|
}
|
|
|
|
try {
|
|
return await post(
|
|
serverConfig.BASE_SERVER + '/secret',
|
|
{
|
|
name,
|
|
value,
|
|
},
|
|
{
|
|
'X-ACTUAL-TOKEN': userToken,
|
|
},
|
|
);
|
|
} catch (error) {
|
|
return {
|
|
error: 'failed',
|
|
reason: error instanceof PostError ? error.reason : undefined,
|
|
};
|
|
}
|
|
}
|
|
async function checkSecret(name: string) {
|
|
const userToken = await asyncStorage.getItem('user-token');
|
|
|
|
if (!userToken) {
|
|
return { error: 'unauthorized' };
|
|
}
|
|
|
|
const serverConfig = getServer();
|
|
if (!serverConfig) {
|
|
throw new Error('Failed to get server config.');
|
|
}
|
|
|
|
try {
|
|
return await get(serverConfig.BASE_SERVER + '/secret/' + name, {
|
|
'X-ACTUAL-TOKEN': userToken,
|
|
});
|
|
} catch (error) {
|
|
logger.error(error);
|
|
return { error: 'failed' };
|
|
}
|
|
}
|
|
|
|
let stopPolling = false;
|
|
|
|
async function pollGoCardlessWebToken({
|
|
requisitionId,
|
|
}: {
|
|
requisitionId: string;
|
|
}) {
|
|
const userToken = await asyncStorage.getItem('user-token');
|
|
if (!userToken) return { error: 'unknown' };
|
|
|
|
const startTime = Date.now();
|
|
stopPolling = false;
|
|
|
|
async function getData(
|
|
cb: (
|
|
data:
|
|
| { status: 'timeout' }
|
|
| { status: 'unknown'; message?: string }
|
|
| { status: 'success'; data: GoCardlessToken },
|
|
) => void,
|
|
) {
|
|
if (stopPolling) {
|
|
return;
|
|
}
|
|
|
|
if (Date.now() - startTime >= 1000 * 60 * 10) {
|
|
cb({ status: 'timeout' });
|
|
return;
|
|
}
|
|
|
|
const serverConfig = getServer();
|
|
if (!serverConfig) {
|
|
throw new Error('Failed to get server config.');
|
|
}
|
|
|
|
const data = await post(
|
|
serverConfig.GOCARDLESS_SERVER + '/get-accounts',
|
|
{
|
|
requisitionId,
|
|
},
|
|
{
|
|
'X-ACTUAL-TOKEN': userToken,
|
|
},
|
|
);
|
|
|
|
if (data) {
|
|
if (data.error_code) {
|
|
logger.error('Failed linking gocardless account:', data);
|
|
cb({ status: 'unknown', message: data.error_type });
|
|
} else {
|
|
cb({ status: 'success', data });
|
|
}
|
|
} else {
|
|
setTimeout(() => getData(cb), 3000);
|
|
}
|
|
}
|
|
|
|
return new Promise(resolve => {
|
|
getData(data => {
|
|
if (data.status === 'success') {
|
|
resolve({ data: data.data });
|
|
return;
|
|
}
|
|
|
|
if (data.status === 'timeout') {
|
|
resolve({ error: data.status });
|
|
return;
|
|
}
|
|
|
|
resolve({
|
|
error: data.status,
|
|
message: data.message,
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
async function stopGoCardlessWebTokenPolling() {
|
|
stopPolling = true;
|
|
return 'ok';
|
|
}
|
|
|
|
async function goCardlessStatus() {
|
|
const userToken = await asyncStorage.getItem('user-token');
|
|
|
|
if (!userToken) {
|
|
return { error: 'unauthorized' };
|
|
}
|
|
|
|
const serverConfig = getServer();
|
|
if (!serverConfig) {
|
|
throw new Error('Failed to get server config.');
|
|
}
|
|
|
|
return post(
|
|
serverConfig.GOCARDLESS_SERVER + '/status',
|
|
{},
|
|
{
|
|
'X-ACTUAL-TOKEN': userToken,
|
|
},
|
|
);
|
|
}
|
|
|
|
async function simpleFinStatus() {
|
|
const userToken = await asyncStorage.getItem('user-token');
|
|
|
|
if (!userToken) {
|
|
return { error: 'unauthorized' };
|
|
}
|
|
|
|
const serverConfig = getServer();
|
|
if (!serverConfig) {
|
|
throw new Error('Failed to get server config.');
|
|
}
|
|
|
|
return post(
|
|
serverConfig.SIMPLEFIN_SERVER + '/status',
|
|
{},
|
|
{
|
|
'X-ACTUAL-TOKEN': userToken,
|
|
},
|
|
);
|
|
}
|
|
|
|
async function pluggyAiStatus() {
|
|
const userToken = await asyncStorage.getItem('user-token');
|
|
|
|
if (!userToken) {
|
|
return { error: 'unauthorized' };
|
|
}
|
|
|
|
const serverConfig = getServer();
|
|
if (!serverConfig) {
|
|
throw new Error('Failed to get server config.');
|
|
}
|
|
|
|
return post(
|
|
serverConfig.PLUGGYAI_SERVER + '/status',
|
|
{},
|
|
{
|
|
'X-ACTUAL-TOKEN': userToken,
|
|
},
|
|
);
|
|
}
|
|
|
|
async function simpleFinAccounts() {
|
|
const userToken = await asyncStorage.getItem('user-token');
|
|
|
|
if (!userToken) {
|
|
return { error: 'unauthorized' };
|
|
}
|
|
|
|
const serverConfig = getServer();
|
|
if (!serverConfig) {
|
|
throw new Error('Failed to get server config.');
|
|
}
|
|
|
|
try {
|
|
return await post(
|
|
serverConfig.SIMPLEFIN_SERVER + '/accounts',
|
|
{},
|
|
{
|
|
'X-ACTUAL-TOKEN': userToken,
|
|
},
|
|
60000,
|
|
);
|
|
} catch (error) {
|
|
return { error_code: 'TIMED_OUT' };
|
|
}
|
|
}
|
|
|
|
async function pluggyAiAccounts() {
|
|
const userToken = await asyncStorage.getItem('user-token');
|
|
|
|
if (!userToken) {
|
|
return { error: 'unauthorized' };
|
|
}
|
|
|
|
const serverConfig = getServer();
|
|
if (!serverConfig) {
|
|
throw new Error('Failed to get server config.');
|
|
}
|
|
|
|
try {
|
|
return await post(
|
|
serverConfig.PLUGGYAI_SERVER + '/accounts',
|
|
{},
|
|
{
|
|
'X-ACTUAL-TOKEN': userToken,
|
|
},
|
|
60000,
|
|
);
|
|
} catch (error) {
|
|
return { error_code: 'TIMED_OUT' };
|
|
}
|
|
}
|
|
|
|
async function getGoCardlessBanks(country: string) {
|
|
const userToken = await asyncStorage.getItem('user-token');
|
|
|
|
if (!userToken) {
|
|
return { error: 'unauthorized' };
|
|
}
|
|
|
|
const serverConfig = getServer();
|
|
if (!serverConfig) {
|
|
throw new Error('Failed to get server config.');
|
|
}
|
|
|
|
return post(
|
|
serverConfig.GOCARDLESS_SERVER + '/get-banks',
|
|
{ country, showDemo: isNonProductionEnvironment() },
|
|
{
|
|
'X-ACTUAL-TOKEN': userToken,
|
|
},
|
|
);
|
|
}
|
|
|
|
async function createGoCardlessWebToken({
|
|
institutionId,
|
|
accessValidForDays,
|
|
}: {
|
|
institutionId: string;
|
|
accessValidForDays: number;
|
|
}) {
|
|
const userToken = await asyncStorage.getItem('user-token');
|
|
|
|
if (!userToken) {
|
|
return { error: 'unauthorized' };
|
|
}
|
|
|
|
const serverConfig = getServer();
|
|
if (!serverConfig) {
|
|
throw new Error('Failed to get server config.');
|
|
}
|
|
|
|
try {
|
|
return await post(
|
|
serverConfig.GOCARDLESS_SERVER + '/create-web-token',
|
|
{
|
|
institutionId,
|
|
accessValidForDays,
|
|
},
|
|
{
|
|
'X-ACTUAL-TOKEN': userToken,
|
|
},
|
|
);
|
|
} catch (error) {
|
|
logger.error(error);
|
|
return { error: 'failed' };
|
|
}
|
|
}
|
|
|
|
type SyncResponse = {
|
|
newTransactions: Array<TransactionEntity['id']>;
|
|
matchedTransactions: Array<TransactionEntity['id']>;
|
|
updatedAccounts: Array<AccountEntity['id']>;
|
|
};
|
|
|
|
async function handleSyncResponse(
|
|
res: {
|
|
added: Array<TransactionEntity['id']>;
|
|
updated: Array<TransactionEntity['id']>;
|
|
},
|
|
acct: db.DbAccount,
|
|
): Promise<SyncResponse> {
|
|
const { added, updated } = res;
|
|
const newTransactions: Array<TransactionEntity['id']> = [];
|
|
const matchedTransactions: Array<TransactionEntity['id']> = [];
|
|
const updatedAccounts: Array<AccountEntity['id']> = [];
|
|
|
|
newTransactions.push(...added);
|
|
matchedTransactions.push(...updated);
|
|
|
|
if (added.length > 0) {
|
|
updatedAccounts.push(acct.id);
|
|
}
|
|
|
|
const ts = new Date().getTime().toString();
|
|
await db.update('accounts', { id: acct.id, last_sync: ts });
|
|
|
|
return {
|
|
newTransactions,
|
|
matchedTransactions,
|
|
updatedAccounts,
|
|
};
|
|
}
|
|
|
|
type SyncError =
|
|
| {
|
|
type: 'SyncError';
|
|
accountId: AccountEntity['id'];
|
|
message: string;
|
|
category: string;
|
|
code: string;
|
|
}
|
|
| {
|
|
accountId: AccountEntity['id'];
|
|
message: string;
|
|
internal?: string;
|
|
};
|
|
|
|
function handleSyncError(
|
|
err: Error | PostError | BankSyncError,
|
|
acct: db.DbAccount,
|
|
): SyncError {
|
|
// TODO: refactor bank sync logic to use BankSyncError properly
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
if (err instanceof BankSyncError || (err as any)?.type === 'BankSyncError') {
|
|
const error = err as BankSyncError;
|
|
|
|
// Use the reason from plugin if available, otherwise use default message
|
|
let message = 'Failed syncing account "' + acct.name + '."';
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
if ((error as any).reason) {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
message = (error as any).reason;
|
|
} else if (error.category === 'RATE_LIMIT_EXCEEDED') {
|
|
message = `Failed syncing account ${acct.name}. Rate limit exceeded. Please try again later.`;
|
|
}
|
|
|
|
const syncError = {
|
|
type: 'SyncError',
|
|
accountId: acct.id,
|
|
message,
|
|
category: error.category,
|
|
code: error.code,
|
|
};
|
|
|
|
return syncError;
|
|
}
|
|
|
|
if (err instanceof PostError && err.reason !== 'internal') {
|
|
return {
|
|
accountId: acct.id,
|
|
message: err.reason
|
|
? err.reason
|
|
: `Account “${acct.name}” is not linked properly. Please link it again.`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
accountId: acct.id,
|
|
message:
|
|
'There was an internal error. Please get in touch https://actualbudget.org/contact for support.',
|
|
internal: err.stack,
|
|
};
|
|
}
|
|
|
|
export type SyncResponseWithErrors = SyncResponse & {
|
|
errors: SyncError[];
|
|
};
|
|
|
|
async function accountsBankSync({
|
|
ids = [],
|
|
}: {
|
|
ids: Array<AccountEntity['id']>;
|
|
}): Promise<SyncResponseWithErrors> {
|
|
const { 'user-id': userId, 'user-key': userKey } =
|
|
await asyncStorage.multiGet(['user-id', 'user-key']);
|
|
|
|
const accounts = await db.runQuery<
|
|
db.DbAccount & { bankId: db.DbBank['bank_id'] }
|
|
>(
|
|
`
|
|
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
|
|
${ids.length ? `AND a.id IN (${ids.map(() => '?').join(', ')})` : ''}
|
|
ORDER BY a.offbudget, a.sort_order
|
|
`,
|
|
ids,
|
|
true,
|
|
);
|
|
|
|
const errors: ReturnType<typeof handleSyncError>[] = [];
|
|
const newTransactions: Array<TransactionEntity['id']> = [];
|
|
const matchedTransactions: Array<TransactionEntity['id']> = [];
|
|
const updatedAccounts: Array<AccountEntity['id']> = [];
|
|
|
|
for (const acct of accounts) {
|
|
if (acct.bankId && acct.account_id) {
|
|
try {
|
|
logger.group('Bank Sync operation for account:', acct.name);
|
|
const syncResponse = await bankSync.syncAccount(
|
|
userId as string,
|
|
userKey as string,
|
|
acct.id,
|
|
acct.account_id,
|
|
acct.bankId,
|
|
);
|
|
|
|
const syncResponseData = await handleSyncResponse(syncResponse, acct);
|
|
|
|
newTransactions.push(...syncResponseData.newTransactions);
|
|
matchedTransactions.push(...syncResponseData.matchedTransactions);
|
|
updatedAccounts.push(...syncResponseData.updatedAccounts);
|
|
} catch (err) {
|
|
const error = err as Error;
|
|
errors.push(handleSyncError(error, acct));
|
|
captureException({
|
|
...error,
|
|
message: 'Failed syncing account “' + acct.name + '.”',
|
|
} as Error);
|
|
} finally {
|
|
logger.groupEnd();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (updatedAccounts.length > 0) {
|
|
connection.send('sync-event', {
|
|
type: 'success',
|
|
tables: ['transactions'],
|
|
});
|
|
}
|
|
|
|
return { errors, newTransactions, matchedTransactions, updatedAccounts };
|
|
}
|
|
|
|
async function simpleFinBatchSync({
|
|
ids = [],
|
|
}: {
|
|
ids: Array<AccountEntity['id']>;
|
|
}): Promise<
|
|
Array<{ accountId: AccountEntity['id']; res: SyncResponseWithErrors }>
|
|
> {
|
|
const accounts = await db.runQuery<
|
|
db.DbAccount & { bankId: db.DbBank['bank_id'] }
|
|
>(
|
|
`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.account_sync_source = 'simpleFin'
|
|
${ids.length ? `AND a.id IN (${ids.map(() => '?').join(', ')})` : ''}
|
|
ORDER BY a.offbudget, a.sort_order`,
|
|
ids.length ? ids : [],
|
|
true,
|
|
);
|
|
|
|
const retVal: Array<{
|
|
accountId: AccountEntity['id'];
|
|
res: {
|
|
errors: ReturnType<typeof handleSyncError>[];
|
|
newTransactions: Array<TransactionEntity['id']>;
|
|
matchedTransactions: Array<TransactionEntity['id']>;
|
|
updatedAccounts: Array<AccountEntity['id']>;
|
|
};
|
|
}> = [];
|
|
|
|
logger.group('Bank Sync operation for all SimpleFin accounts');
|
|
try {
|
|
const syncResponses: Array<{
|
|
accountId: AccountEntity['id'];
|
|
res: {
|
|
error_code: string;
|
|
error_type: string;
|
|
added: Array<TransactionEntity['id']>;
|
|
updated: Array<TransactionEntity['id']>;
|
|
};
|
|
}> = await bankSync.simpleFinBatchSync(
|
|
accounts.map(a => ({
|
|
id: a.id,
|
|
account_id: a.account_id || null,
|
|
})),
|
|
);
|
|
for (const syncResponse of syncResponses) {
|
|
const account = accounts.find(a => a.id === syncResponse.accountId);
|
|
if (!account) {
|
|
logger.error(
|
|
`Invalid account ID found in response: ${syncResponse.accountId}. Proceeding to the next account...`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const errors: ReturnType<typeof handleSyncError>[] = [];
|
|
const newTransactions: Array<TransactionEntity['id']> = [];
|
|
const matchedTransactions: Array<TransactionEntity['id']> = [];
|
|
const updatedAccounts: Array<AccountEntity['id']> = [];
|
|
|
|
if (syncResponse.res.error_code) {
|
|
errors.push(
|
|
handleSyncError(
|
|
{
|
|
type: 'BankSyncError',
|
|
reason: 'Failed syncing account “' + account.name + '.”',
|
|
category: syncResponse.res.error_type,
|
|
code: syncResponse.res.error_code,
|
|
} as BankSyncError,
|
|
account,
|
|
),
|
|
);
|
|
} else {
|
|
const syncResponseData = await handleSyncResponse(
|
|
syncResponse.res,
|
|
account,
|
|
);
|
|
|
|
newTransactions.push(...syncResponseData.newTransactions);
|
|
matchedTransactions.push(...syncResponseData.matchedTransactions);
|
|
updatedAccounts.push(...syncResponseData.updatedAccounts);
|
|
}
|
|
|
|
retVal.push({
|
|
accountId: syncResponse.accountId,
|
|
res: { errors, newTransactions, matchedTransactions, updatedAccounts },
|
|
});
|
|
}
|
|
} catch (err) {
|
|
const errors = [];
|
|
for (const account of accounts) {
|
|
retVal.push({
|
|
accountId: account.id,
|
|
res: {
|
|
errors,
|
|
newTransactions: [],
|
|
matchedTransactions: [],
|
|
updatedAccounts: [],
|
|
},
|
|
});
|
|
const error = err as Error;
|
|
errors.push(handleSyncError(error, account));
|
|
}
|
|
}
|
|
|
|
if (retVal.some(a => a.res.updatedAccounts.length > 0)) {
|
|
connection.send('sync-event', {
|
|
type: 'success',
|
|
tables: ['transactions'],
|
|
});
|
|
}
|
|
|
|
logger.groupEnd();
|
|
|
|
return retVal;
|
|
}
|
|
|
|
export type ImportTransactionsResult = bankSync.ReconcileTransactionsResult & {
|
|
errors: Array<{
|
|
message: string;
|
|
}>;
|
|
};
|
|
|
|
async function importTransactions({
|
|
accountId,
|
|
transactions,
|
|
isPreview,
|
|
opts,
|
|
}: {
|
|
accountId: AccountEntity['id'];
|
|
transactions: ImportTransactionEntity[];
|
|
isPreview: boolean;
|
|
opts?: {
|
|
defaultCleared?: boolean;
|
|
};
|
|
}): Promise<ImportTransactionsResult> {
|
|
if (typeof accountId !== 'string') {
|
|
throw APIError('transactions-import: accountId must be an id');
|
|
}
|
|
|
|
try {
|
|
const reconciled = await bankSync.reconcileTransactions(
|
|
accountId,
|
|
transactions,
|
|
false,
|
|
true,
|
|
isPreview,
|
|
opts?.defaultCleared,
|
|
);
|
|
return {
|
|
errors: [],
|
|
added: reconciled.added,
|
|
updated: reconciled.updated,
|
|
updatedPreview: reconciled.updatedPreview,
|
|
};
|
|
} catch (err) {
|
|
if (err instanceof TransactionError) {
|
|
return {
|
|
errors: [{ message: err.message }],
|
|
added: [],
|
|
updated: [],
|
|
updatedPreview: [],
|
|
};
|
|
}
|
|
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function unlinkAccount({ id }: { id: AccountEntity['id'] }) {
|
|
const accRow = await db.first<db.DbAccount>(
|
|
'SELECT * FROM accounts WHERE id = ?',
|
|
[id],
|
|
);
|
|
|
|
if (!accRow) {
|
|
throw new Error(`Account with ID ${id} not found.`);
|
|
}
|
|
|
|
const bankId = accRow.bank;
|
|
|
|
if (!bankId) {
|
|
return 'ok';
|
|
}
|
|
|
|
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 accountWithBankResult = await db.first<{ count: number }>(
|
|
'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 (!accountWithBankResult || accountWithBankResult.count === 0) {
|
|
const bank = await db.first<Pick<db.DbBank, 'bank_id'>>(
|
|
'SELECT bank_id FROM banks WHERE id = ?',
|
|
[bankId],
|
|
);
|
|
|
|
if (!bank) {
|
|
throw new Error(`Bank with ID ${bankId} not found.`);
|
|
}
|
|
|
|
const serverConfig = getServer();
|
|
if (!serverConfig) {
|
|
throw new Error('Failed to get server config.');
|
|
}
|
|
|
|
const requisitionId = bank.bank_id;
|
|
|
|
try {
|
|
await post(
|
|
serverConfig.GOCARDLESS_SERVER + '/remove-account',
|
|
{
|
|
requisitionId,
|
|
},
|
|
{
|
|
'X-ACTUAL-TOKEN': userToken,
|
|
},
|
|
);
|
|
} catch (error) {
|
|
logger.log({ error });
|
|
}
|
|
}
|
|
|
|
return 'ok';
|
|
}
|
|
|
|
export const app = createApp<AccountHandlers>();
|
|
|
|
app.method('account-update', mutator(undoable(updateAccount)));
|
|
app.method('accounts-get', getAccounts);
|
|
app.method('account-balance', getAccountBalance);
|
|
app.method('account-properties', getAccountProperties);
|
|
app.method('gocardless-accounts-link', linkGoCardlessAccount);
|
|
app.method('simplefin-accounts-link', linkSimpleFinAccount);
|
|
app.method('pluggyai-accounts-link', linkPluggyAiAccount);
|
|
app.method('bank-sync-providers-list', getPluginProviders);
|
|
app.method('bank-sync-status', getPluginStatus);
|
|
app.method('bank-sync-accounts', getPluginAccounts);
|
|
app.method('bank-sync-accounts-link', linkPluginAccount);
|
|
app.method('account-create', mutator(undoable(createAccount)));
|
|
app.method('account-close', mutator(closeAccount));
|
|
app.method('account-reopen', mutator(undoable(reopenAccount)));
|
|
app.method('account-move', mutator(undoable(moveAccount)));
|
|
app.method('secret-set', setSecret);
|
|
app.method('secret-check', checkSecret);
|
|
app.method('gocardless-poll-web-token', pollGoCardlessWebToken);
|
|
app.method('gocardless-poll-web-token-stop', stopGoCardlessWebTokenPolling);
|
|
app.method('gocardless-status', goCardlessStatus);
|
|
app.method('simplefin-status', simpleFinStatus);
|
|
app.method('pluggyai-status', pluggyAiStatus);
|
|
app.method('simplefin-accounts', simpleFinAccounts);
|
|
app.method('pluggyai-accounts', pluggyAiAccounts);
|
|
app.method('gocardless-get-banks', getGoCardlessBanks);
|
|
app.method('gocardless-create-web-token', createGoCardlessWebToken);
|
|
app.method('accounts-bank-sync', accountsBankSync);
|
|
app.method('simplefin-batch-sync', simpleFinBatchSync);
|
|
app.method('transactions-import', mutator(undoable(importTransactions)));
|
|
app.method('account-unlink', mutator(unlinkAccount));
|