import type express from 'express'; import { assert, asyncErr, getErrorCode, KError, makeLogger, unwrap } from '../helpers'; import accountManager, { GLOBAL_CONTEXT, type UserActionOrValue } from '../lib/accounts-manager'; import { fullPoll } from '../lib/poller'; import { Access, AccessField, Account, type Transaction, View } from '../models'; import { bankVendorByUuid } from '../providers'; import type { UserActionResponse } from '../shared/types'; import { hasForbiddenField, hasMissingField } from '../shared/validators'; import * as AccountController from './accounts'; import { registerStartupTask } from './all'; import { isDemoEnabled } from './instance'; import type { IdentifiedRequest, PreloadedRequest } from './routes'; const log = makeLogger('controllers/accesses'); type FetchAccountsAndTransactionsResult = { accounts: Account[]; views: View[]; newTransactions: Transaction[]; errors?: string[]; }; // Preloads a bank access (sets @access). export async function preloadAccess( req: IdentifiedRequest, res: express.Response, nextHandler: () => void, accessId: number ) { try { const { id: userId } = req.user; const access = await Access.find(userId, accessId); if (!access) { throw new KError('bank access not found', 404); } req.preloaded = { access }; nextHandler(); } catch (err) { asyncErr(res, err, 'when finding bank access'); } } export async function destroyWithData(userId: number, access: Access) { log.info(`Removing access ${access.id} for bank ${access.vendorId}...`); await Access.destroy(userId, access.id); await AccountController.fixupDefaultAccount(userId); log.info('Done!'); } // Destroy a given access, including accounts, alerts and transactions. export async function destroy(req: PreloadedRequest, res: express.Response) { try { const { user: { id: userId }, } = req; if (await isDemoEnabled(userId)) { throw new KError("access deletion isn't allowed in demo mode", 400); } await destroyWithData(userId, req.preloaded.access); res.status(204).end(); } catch (err) { asyncErr(res, err, 'when destroying an access'); } } export async function deleteSession(req: PreloadedRequest, res: express.Response) { try { const { user: { id: userId }, } = req; const { access } = req.preloaded; const session = GLOBAL_CONTEXT.getUserSession(userId); await session.reset(access); res.status(204).end(); } catch (err) { asyncErr(res, err, 'when deleting an access session'); } } export interface CreateAndRetrieveDataResult { accessId: number; accounts: Account[]; views: View[]; newTransactions: Transaction[]; label: string; errors?: string[]; enabled: boolean; } export function extractUserActionFields(body: Record | undefined) { if (!body) { return null; } const fields = (body.userActionFields || null) as Record | null; delete body.userActionFields; return fields; } export async function createAndRetrieveData( userId: number, params: Record, storeCredentials = true ): Promise> { const error = hasMissingField(params, ['vendorId', 'fields']) || hasForbiddenField(params, [ 'vendorId', 'fields', 'customLabel', 'userActionFields', 'excludeFromPoll', 'accessId', ]); if (error) { throw new KError(`when creating a new access: ${error}`, 400); } assert(Array.isArray(params.fields), 'fields should be an array'); const userActionFields = extractUserActionFields(params as Record); let access: Access | null = null; try { if (userActionFields !== null) { // The access must exist, as this is a second step of a 2FA. Use the access id // provided in the first step's response to find it. assert( typeof params.accessId === 'number', 'accessId is required for 2FA continuation' ); access = unwrap(await Access.find(userId, params.accessId)); } else if (storeCredentials) { access = await Access.create(userId, params); } else { // Create the access without storing fields. access = await Access.create(userId, { ...params, fields: undefined, }); } // Store the access enablement status now, before re-injecting fields. const wasAccessEnabled = access.isEnabled(); // Re-inject fields in memory only for accesses that need it (storeCredentials set to false). if (!wasAccessEnabled && params.fields) { access.fields = params.fields as AccessField[]; } let providerErrors: string[] = []; const accountResponse = await accountManager.syncAccounts(userId, access, { addNewAccounts: true, updateProvider: false, // TODO infer from setting? isInteractive: true, userActionFields, }); if (accountResponse.kind === 'user_action') { // The whole system relies on the Access object existing (in // particular, the session with 2fa information is tied to the // Access), so we can't delete the Access object here. // // Unfortunately, because of 2fa, this means the user can abort the // access creation and leave an inconsistent state in the database, // where we have an Access but there's no Account/Transaction tied. // // So we register a special task that gets run on /api/all (= next // loading of Kresus), which will clean the access if it has no // associated accounts, as a proxy of meaning the 2fa has never // completed. const prevAccess: Access = access; registerStartupTask(userId, async () => { const accounts = await Account.byAccess(userId, prevAccess); if (accounts.length === 0) { log.info(`Cleaning up incomplete access with id ${prevAccess.id}`); await Access.destroy(userId, prevAccess.id); } }); return { ...accountResponse, accessId: access.id }; } const accountInfoMap = accountResponse.value; if (accountResponse.errors) { providerErrors = providerErrors.concat(accountResponse.errors); } const transactionResponse = await accountManager.syncTransactions( userId, access, accountInfoMap, /* isInteractive */ true ); assert( transactionResponse.kind !== 'user_action', 'user action should have been requested when fetching accounts' ); if (transactionResponse.errors) { providerErrors = providerErrors.concat(transactionResponse.errors); } const { accounts, createdTransactions: newTransactions } = transactionResponse.value; // New views have automatically been created along with accounts. const views = await View.all(userId); return { kind: 'value', value: { accessId: access.id, accounts, views, newTransactions, label: bankVendorByUuid(access.vendorId).name, errors: providerErrors, enabled: wasAccessEnabled, }, }; } catch (err) { log.error('The access process creation failed, cleaning up...'); // Let sql remove all the dependent data for us. if (access !== null) { log.info('\tdeleting access...'); await Access.destroy(userId, access.id); } // Rethrow the error throw err; } } // Creates a new bank access (expecting at least (vendorId / login / // password)), and retrieves its accounts and transactions. export async function create(req: IdentifiedRequest, res: express.Response) { try { const { user: { id: userId }, } = req; if (await isDemoEnabled(userId)) { throw new KError("access creation isn't allowed in demo mode", 400); } const storeCredentials = req.body.storeCredentials !== false; delete req.body.storeCredentials; const data = await createAndRetrieveData(userId, req.body, storeCredentials); if (data.kind === 'user_action') { res.status(200).json(data); } else { res.status(201).json(data.value); } } catch (err) { asyncErr(res, err, 'when creating a bank access'); } } // Do not use this method as a controller directly: express will pass a `next` middleware as third // argument instead of the expected params object. const _fetchAccountsAndTransactions = async ( req: PreloadedRequest, params: { // Whether accounts returned by the fetch and absent from database should be created. addNewAccounts: boolean; // Whether an error while fetching accounts should stop the whole fetch (there are cases // where we still want to fetch the transactions from working accounts). requiresSuccessfulAccountsSync: boolean; } ): Promise => { const { addNewAccounts, requiresSuccessfulAccountsSync } = params; const { id: userId } = req.user; const access = req.preloaded.access; const bankVendor = bankVendorByUuid(access.vendorId); if (!access.isEnabled() || bankVendor.deprecated) { const errcode = getErrorCode('DISABLED_ACCESS'); throw new KError('disabled access', 403, errcode); } const userActionFields = extractUserActionFields(req.body); let providerErrors: string[] = []; let accountResponse: Awaited> | null = null; // To deal with banks that often throw errors when dealing with recurrent requests, // we wrap the accounts update requests in a try/catch, and still fetch the transactions // if it fails, if addnewsAccounts is true: we likely are in a poll and the updated accounts // are really important: we should throw an error. Else, in a transactions fetch, // the balance might be out of sync with the new transactions but we consider it // a minor issue. try { accountResponse = await accountManager.syncAccounts(userId, access, { addNewAccounts, updateProvider: false, // TODO shouldn't this be inferred from the settings? isInteractive: true, userActionFields, }); } catch (err) { if (requiresSuccessfulAccountsSync) { throw err; } } if (accountResponse && accountResponse.kind === 'user_action') { return accountResponse; } const accountInfoMap = accountResponse ? accountResponse.value : null; if (accountResponse && accountResponse.errors) { providerErrors = providerErrors.concat(accountResponse.errors); } const transactionResponse = await accountManager.syncTransactions( userId, access, accountInfoMap, /* isInteractive */ true ); assert( transactionResponse.kind !== 'user_action', 'user action should have been requested when fetching accounts' ); if (transactionResponse.errors) { providerErrors = providerErrors.concat(transactionResponse.errors); } const { accounts, createdTransactions: newTransactions } = transactionResponse.value; // New views have automatically been created along with accounts. const views = await View.all(userId); return { accounts, views, newTransactions, errors: providerErrors }; }; // Fetch accounts, including new accounts, and transactions using the backend and // return both to the client. export async function fetchAccountsAndTransactions( req: PreloadedRequest, res: express.Response ) { try { const result = await _fetchAccountsAndTransactions(req, { addNewAccounts: true, requiresSuccessfulAccountsSync: true, }); res.status(200).json(result); } catch (err) { asyncErr(res, err, 'when fetching accounts and transactions'); } } // Fetch accounts (for up-to-date balances) transactions using the backend and return the transactions to the client. // Does not add new found accounts. export async function fetchTransactions(req: PreloadedRequest, res: express.Response) { try { const result = await _fetchAccountsAndTransactions(req, { addNewAccounts: false, requiresSuccessfulAccountsSync: false, }); res.status(200).json(result); } catch (err) { asyncErr(res, err, 'when fetching accounts and transactions'); } } // Fetch all the transactions / accounts for all the accesses, as is done during // any regular poll. export async function poll(req: IdentifiedRequest, res: express.Response) { try { const { id: userId } = req.user; await fullPoll(userId); res.status(200).json({ status: 'OK', }); } catch (err) { log.warn(`Error when doing a full poll: ${err.message}`); res.status(500).json({ status: 'error', message: err.message, }); } } // Updates a bank access. export async function update(req: PreloadedRequest, res: express.Response) { try { const { id: userId } = req.user; const { access } = req.preloaded; const attrs = req.body; const error = hasForbiddenField(attrs, ['enabled', 'customLabel', 'excludeFromPoll']); if (error) { throw new KError(`when updating an access: ${error}`, 400); } if (attrs.enabled === false) { await AccessField.destroyAllFromAccessId(userId, access.id); delete attrs.enabled; } if (attrs.customLabel === '') { attrs.customLabel = null; } await Access.update(userId, access.id, attrs); res.status(201).json({ status: 'OK' }); } catch (err) { asyncErr(res, err, 'when updating bank access'); } } export async function updateAndFetchAccounts(req: PreloadedRequest, res: express.Response) { try { const { id: userId } = req.user; const { access } = req.preloaded; const attrs = req.body; const error = hasForbiddenField(attrs, [ 'login', 'password', 'fields', 'userActionFields', 'storeCredentials', ]); if (error) { throw new KError(`when updating and polling an access: ${error}`, 400); } // Hack: temporarily remove userActionFields from the entity, so the // ORM accepts it. Oh well. const { userActionFields } = attrs; delete attrs.userActionFields; const providedAccessFields: AccessField[] | undefined = attrs.fields; delete attrs.fields; const wasAccessEnabled = access.isEnabled(); const storeCredentials = !!attrs.storeCredentials; delete attrs.storeCredentials; if (typeof providedAccessFields !== 'undefined') { if (storeCredentials) { // Persist the credential updates to DB. for (const { name, value } of providedAccessFields) { const previous = access.fields.find(existing => existing.name === name); if (value === null) { // Delete the custom field if necessary. if (typeof previous !== 'undefined') { await AccessField.destroy(userId, previous.id); } } else if (typeof previous !== 'undefined') { // Update the custom field if necessary. if (previous.value !== value) { await AccessField.update(userId, previous.id, { name, value }); } } else { // Create it. await AccessField.create(userId, { name, value, accessId: access.id }); } } } } else if (!wasAccessEnabled) { throw new KError( 'when updating and fetching bank access: access disabled and no access fields provided by user', 401 ); } // The preloaded access needs to be updated before calling fetchAccounts. req.preloaded.access = await Access.update(userId, access.id, attrs); if (!storeCredentials) { // Make sure previous credentials are erased. await AccessField.destroyAllFromAccessId(userId, access.id); // Since fields are not stored in DB, re-apply in-memory fields after the DB reload (Access.update reloads from DB). if (providedAccessFields) { req.preloaded.access.fields = providedAccessFields; } } // Hack: reset userActionFields (see above comment). req.body.userActionFields = userActionFields; const result = await _fetchAccountsAndTransactions(req, { addNewAccounts: true, requiresSuccessfulAccountsSync: true, }); if ('kind' in result) { res.status(200).json(result); return; } res.status(200).json({ ...result, enabled: storeCredentials }); } catch (err) { asyncErr(res, err, 'when updating and fetching bank access'); } }