Files
actual/packages/loot-core/src/server/accounts/app.ts
2025-10-08 15:28:17 -03:00

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));