diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx index 30055290261..ecd7ee692ba 100644 --- a/packages/desktop-client/src/components/Modals.tsx +++ b/packages/desktop-client/src/components/Modals.tsx @@ -61,6 +61,7 @@ import { OpenIDEnableModal } from './modals/OpenIDEnableModal'; import { OutOfSyncMigrationsModal } from './modals/OutOfSyncMigrationsModal'; import { PasswordEnableModal } from './modals/PasswordEnableModal'; import { PayeeAutocompleteModal } from './modals/PayeeAutocompleteModal'; +import { PluggyAiInitialiseModal } from './modals/PluggyAiInitialiseModal'; import { ScheduledTransactionMenuModal } from './modals/ScheduledTransactionMenuModal'; import { SelectLinkedAccountsModal } from './modals/SelectLinkedAccountsModal'; import { SimpleFinInitialiseModal } from './modals/SimpleFinInitialiseModal'; @@ -222,6 +223,11 @@ export function Modals() { /> ); + case 'pluggyai-init': + return ( + + ); + case 'gocardless-external-msg': return ( { const syncSourceReadable: Record = { goCardless: 'GoCardless', simpleFin: 'SimpleFIN', + pluggyai: 'Pluggy.ai', unlinked: t('Unlinked'), }; diff --git a/packages/desktop-client/src/components/modals/CreateAccountModal.tsx b/packages/desktop-client/src/components/modals/CreateAccountModal.tsx index 579be8b00d7..f027847c53c 100644 --- a/packages/desktop-client/src/components/modals/CreateAccountModal.tsx +++ b/packages/desktop-client/src/components/modals/CreateAccountModal.tsx @@ -10,13 +10,15 @@ import { Popover } from '@actual-app/components/popover'; import { Text } from '@actual-app/components/text'; import { View } from '@actual-app/components/view'; -import { pushModal } from 'loot-core/client/actions'; +import { addNotification, pushModal } from 'loot-core/client/actions'; import { send } from 'loot-core/platform/client/fetch'; import { useAuth } from '../../auth/AuthProvider'; import { Permissions } from '../../auth/types'; import { authorizeBank } from '../../gocardless'; +import { useFeatureFlag } from '../../hooks/useFeatureFlag'; import { useGoCardlessStatus } from '../../hooks/useGoCardlessStatus'; +import { usePluggyAiStatus } from '../../hooks/usePluggyAiStatus'; import { useSimpleFinStatus } from '../../hooks/useSimpleFinStatus'; import { useSyncServerStatus } from '../../hooks/useSyncServerStatus'; import { SvgDotsHorizontalTriple } from '../../icons/v1'; @@ -34,6 +36,8 @@ type CreateAccountProps = { export function CreateAccountModal({ upgradingAccountId }: CreateAccountProps) { const { t } = useTranslation(); + const isPluggyAiEnabled = useFeatureFlag('pluggyAiBankSync'); + const syncServerStatus = useSyncServerStatus(); const dispatch = useDispatch(); const [isGoCardlessSetupComplete, setIsGoCardlessSetupComplete] = useState< @@ -42,6 +46,9 @@ export function CreateAccountModal({ upgradingAccountId }: CreateAccountProps) { const [isSimpleFinSetupComplete, setIsSimpleFinSetupComplete] = useState< boolean | null >(null); + const [isPluggyAiSetupComplete, setIsPluggyAiSetupComplete] = useState< + boolean | null + >(null); const { hasPermission } = useAuth(); const multiuserEnabled = useMultiuserEnabled(); @@ -118,6 +125,70 @@ export function CreateAccountModal({ upgradingAccountId }: CreateAccountProps) { setLoadingSimpleFinAccounts(false); }; + const onConnectPluggyAi = async () => { + if (!isPluggyAiSetupComplete) { + onPluggyAiInit(); + return; + } + + try { + const results = await send('pluggyai-accounts'); + if (results.error_code) { + throw new Error(results.reason); + } else if ('error' in results) { + throw new Error(results.error); + } + + const newAccounts = []; + + type NormalizedAccount = { + account_id: string; + name: string; + institution: string; + orgDomain: string | null; + orgId: string; + balance: number; + }; + + for (const oldAccount of results.accounts) { + const newAccount: NormalizedAccount = { + account_id: oldAccount.id, + name: `${oldAccount.name.trim()} - ${oldAccount.type === 'BANK' ? oldAccount.taxNumber : oldAccount.owner}`, + institution: oldAccount.name, + orgDomain: null, + orgId: oldAccount.id, + balance: + oldAccount.type === 'BANK' + ? oldAccount.bankData.automaticallyInvestedBalance + + oldAccount.bankData.closingBalance + : oldAccount.balance, + }; + + newAccounts.push(newAccount); + } + + dispatch( + pushModal('select-linked-accounts', { + accounts: newAccounts, + syncSource: 'pluggyai', + }), + ); + } catch (err) { + console.error(err); + addNotification({ + type: 'error', + title: t('Error when trying to contact Pluggy.ai'), + message: (err as Error).message, + timeout: 5000, + }); + dispatch( + pushModal('pluggyai-init', { + onSuccess: () => setIsPluggyAiSetupComplete(true), + }), + ); + } + }; + const onGoCardlessInit = () => { dispatch( pushModal('gocardless-init', { @@ -134,6 +205,14 @@ export function CreateAccountModal({ upgradingAccountId }: CreateAccountProps) { ); }; + const onPluggyAiInit = () => { + dispatch( + pushModal('pluggyai-init', { + onSuccess: () => setIsPluggyAiSetupComplete(true), + }), + ); + }; + const onGoCardlessReset = () => { send('secret-set', { name: 'gocardless_secretId', @@ -162,6 +241,25 @@ export function CreateAccountModal({ upgradingAccountId }: CreateAccountProps) { }); }; + const onPluggyAiReset = () => { + send('secret-set', { + name: 'pluggyai_clientId', + value: null, + }).then(() => { + send('secret-set', { + name: 'pluggyai_clientSecret', + value: null, + }).then(() => { + send('secret-set', { + name: 'pluggyai_itemIds', + value: null, + }).then(() => { + setIsPluggyAiSetupComplete(false); + }); + }); + }); + }; + const onCreateLocalAccount = () => { dispatch(pushModal('add-local-account')); }; @@ -176,6 +274,11 @@ export function CreateAccountModal({ upgradingAccountId }: CreateAccountProps) { setIsSimpleFinSetupComplete(configuredSimpleFin); }, [configuredSimpleFin]); + const { configuredPluggyAi } = usePluggyAiStatus(); + useEffect(() => { + setIsPluggyAiSetupComplete(configuredPluggyAi); + }, [configuredPluggyAi]); + let title = t('Add account'); const [loadingSimpleFinAccounts, setLoadingSimpleFinAccounts] = useState(false); @@ -359,9 +462,77 @@ export function CreateAccountModal({ upgradingAccountId }: CreateAccountProps) { hundreds of banks. + {isPluggyAiEnabled && ( + <> + + + {isPluggyAiSetupComplete + ? t('Link bank account with Pluggy.ai') + : t('Set up Pluggy.ai for bank sync')} + + {isPluggyAiSetupComplete && ( + + + + + { + if (item === 'reconfigure') { + onPluggyAiReset(); + } + }} + items={[ + { + name: 'reconfigure', + text: t('Reset Pluggy.ai credentials'), + }, + ]} + /> + + + )} + + + + + Link a Brazilian bank account + {' '} + to automatically download transactions. Pluggy.ai + provides reliable, up-to-date information from + hundreds of banks. + + + + )} )} - {(!isGoCardlessSetupComplete || !isSimpleFinSetupComplete) && + {(!isGoCardlessSetupComplete || + !isSimpleFinSetupComplete || + !isPluggyAiSetupComplete) && !canSetSecrets && ( @@ -371,6 +542,7 @@ export function CreateAccountModal({ upgradingAccountId }: CreateAccountProps) { {[ isGoCardlessSetupComplete ? '' : 'GoCardless', isSimpleFinSetupComplete ? '' : 'SimpleFin', + isPluggyAiSetupComplete ? '' : 'Pluggy.ai', ] .filter(Boolean) .join(' or ')} diff --git a/packages/desktop-client/src/components/modals/PluggyAiInitialiseModal.tsx b/packages/desktop-client/src/components/modals/PluggyAiInitialiseModal.tsx new file mode 100644 index 00000000000..547f3b9b172 --- /dev/null +++ b/packages/desktop-client/src/components/modals/PluggyAiInitialiseModal.tsx @@ -0,0 +1,191 @@ +// @ts-strict-ignore +import React, { useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { ButtonWithLoading } from '@actual-app/components/button'; +import { InitialFocus } from '@actual-app/components/initial-focus'; +import { Text } from '@actual-app/components/text'; +import { View } from '@actual-app/components/view'; + +import { getSecretsError } from 'loot-core/shared/errors'; +import { send } from 'loot-core/src/platform/client/fetch'; + +import { Error } from '../alerts'; +import { Input } from '../common/Input'; +import { Link } from '../common/Link'; +import { + Modal, + ModalButtons, + ModalCloseButton, + ModalHeader, +} from '../common/Modal'; +import { FormField, FormLabel } from '../forms'; + +type PluggyAiInitialiseProps = { + onSuccess: () => void; +}; + +export const PluggyAiInitialiseModal = ({ + onSuccess, +}: PluggyAiInitialiseProps) => { + const { t } = useTranslation(); + const [clientId, setClientId] = useState(''); + const [clientSecret, setClientSecret] = useState(''); + const [itemIds, setItemIds] = useState(''); + const [isValid, setIsValid] = useState(true); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState( + t( + 'It is required to provide both the client id, client secret and at least one item id.', + ), + ); + + const onSubmit = async (close: () => void) => { + if (!clientId || !clientSecret || !itemIds) { + setIsValid(false); + setError( + t( + 'It is required to provide both the client id, client secret and at least one item id.', + ), + ); + return; + } + + setIsLoading(true); + + let { error, reason } = + (await send('secret-set', { + name: 'pluggyai_clientId', + value: clientId, + })) || {}; + + if (error) { + setIsLoading(false); + setIsValid(false); + setError(getSecretsError(error, reason)); + return; + } else { + ({ error, reason } = + (await send('secret-set', { + name: 'pluggyai_clientSecret', + value: clientSecret, + })) || {}); + if (error) { + setIsLoading(false); + setIsValid(false); + setError(getSecretsError(error, reason)); + return; + } else { + ({ error, reason } = + (await send('secret-set', { + name: 'pluggyai_itemIds', + value: itemIds, + })) || {}); + + if (error) { + setIsLoading(false); + setIsValid(false); + setError(getSecretsError(error, reason)); + return; + } + } + } + + setIsValid(true); + onSuccess(); + setIsLoading(false); + close(); + }; + + return ( + + {({ state: { close } }) => ( + <> + } + /> + + + + In order to enable bank-sync via Pluggy.ai (only for Brazilian + banks) you will need to create access credentials. This can be + done by creating an account with{' '} + + Pluggy.ai + + . + + + + + + + { + setClientId(value); + setIsValid(true); + }} + /> + + + + + + { + setClientSecret(value); + setIsValid(true); + }} + /> + + + + + { + setItemIds(value); + setIsValid(true); + }} + /> + + + {!isValid && {error}} + + + + { + onSubmit(close); + }} + > + Save and continue + + + + )} + + ); +}; diff --git a/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.jsx b/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.jsx index 545c47caefb..f02fdf89392 100644 --- a/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.jsx +++ b/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.jsx @@ -7,6 +7,7 @@ import { View } from '@actual-app/components/view'; import { linkAccount, + linkAccountPluggyAi, linkAccountSimpleFin, unlinkAccount, } from 'loot-core/client/accounts/accountsSlice'; @@ -91,6 +92,18 @@ export function SelectLinkedAccountsModal({ offBudget, }), ); + } else if (syncSource === 'pluggyai') { + dispatch( + linkAccountPluggyAi({ + externalAccount, + upgradingId: + chosenLocalAccountId !== addOnBudgetAccountOption.id && + chosenLocalAccountId !== addOffBudgetAccountOption.id + ? chosenLocalAccountId + : undefined, + offBudget, + }), + ); } else { dispatch( linkAccount({ diff --git a/packages/desktop-client/src/components/settings/Experimental.tsx b/packages/desktop-client/src/components/settings/Experimental.tsx index cb7f6f2875e..1e93b864506 100644 --- a/packages/desktop-client/src/components/settings/Experimental.tsx +++ b/packages/desktop-client/src/components/settings/Experimental.tsx @@ -111,6 +111,12 @@ export function ExperimentalFeatures() { > OpenID authentication method + + Pluggy.ai Bank Sync (Brazilian banks only) + ) : ( = { actionTemplating: false, contextMenus: false, openidAuth: false, + pluggyAiBankSync: false, }; export function useFeatureFlag(name: FeatureFlag): boolean { diff --git a/packages/desktop-client/src/hooks/usePluggyAiStatus.ts b/packages/desktop-client/src/hooks/usePluggyAiStatus.ts new file mode 100644 index 00000000000..70d71a962d0 --- /dev/null +++ b/packages/desktop-client/src/hooks/usePluggyAiStatus.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react'; + +import { send } from 'loot-core/src/platform/client/fetch'; + +import { useSyncServerStatus } from './useSyncServerStatus'; + +export function usePluggyAiStatus() { + const [configuredPluggyAi, setConfiguredPluggyAi] = useState( + null, + ); + const [isLoading, setIsLoading] = useState(false); + const status = useSyncServerStatus(); + + useEffect(() => { + async function fetch() { + setIsLoading(true); + + const results = await send('pluggyai-status'); + + setConfiguredPluggyAi(results.configured || false); + setIsLoading(false); + } + + if (status === 'online') { + fetch(); + } + }, [status]); + + return { + configuredPluggyAi, + isLoading, + }; +} diff --git a/packages/loot-core/src/client/accounts/accountsSlice.ts b/packages/loot-core/src/client/accounts/accountsSlice.ts index 0f032115b14..322a78c5fb8 100644 --- a/packages/loot-core/src/client/accounts/accountsSlice.ts +++ b/packages/loot-core/src/client/accounts/accountsSlice.ts @@ -7,6 +7,7 @@ import { type AccountEntity, type TransactionEntity, type SyncServerSimpleFinAccount, + type SyncServerPluggyAiAccount, } from '../../types/models'; import { addNotification } from '../actions'; import { @@ -130,6 +131,28 @@ export const linkAccountSimpleFin = createAppAsyncThunk( }, ); +type LinkAccountPluggyAiPayload = { + externalAccount: SyncServerPluggyAiAccount; + upgradingId?: AccountEntity['id']; + offBudget?: boolean; +}; + +export const linkAccountPluggyAi = createAppAsyncThunk( + `${sliceName}/linkAccountPluggyAi`, + async ( + { externalAccount, upgradingId, offBudget }: LinkAccountPluggyAiPayload, + { dispatch }, + ) => { + await send('pluggyai-accounts-link', { + externalAccount, + upgradingId, + offBudget, + }); + dispatch(getPayees()); + dispatch(getAccounts()); + }, +); + function handleSyncResponse( accountId: AccountEntity['id'], res: SyncResponseWithErrors, @@ -315,6 +338,7 @@ export const actions = { ...accountsSlice.actions, linkAccount, linkAccountSimpleFin, + linkAccountPluggyAi, moveAccount, unlinkAccount, syncAccounts, diff --git a/packages/loot-core/src/client/state-types/modals.d.ts b/packages/loot-core/src/client/state-types/modals.d.ts index 7fb3c44fdad..4a1ed5249ef 100644 --- a/packages/loot-core/src/client/state-types/modals.d.ts +++ b/packages/loot-core/src/client/state-types/modals.d.ts @@ -69,6 +69,9 @@ type FinanceModals = { 'simplefin-init': { onSuccess: () => void; }; + 'pluggyai-init': { + onSuccess: () => void; + }; 'gocardless-external-msg': { onMoveExternal: (arg: { diff --git a/packages/loot-core/src/server/accounts/app.ts b/packages/loot-core/src/server/accounts/app.ts index d8ee393e7e5..1150b5a2694 100644 --- a/packages/loot-core/src/server/accounts/app.ts +++ b/packages/loot-core/src/server/accounts/app.ts @@ -1,3 +1,4 @@ +import { t } from 'i18next'; import { v4 as uuidv4 } from 'uuid'; import { captureException } from '../../platform/exceptions'; @@ -14,6 +15,7 @@ import { PayeeEntity, TransactionEntity, SyncServerSimpleFinAccount, + SyncServerPluggyAiAccount, } from '../../types/models'; import { BankEntity } from '../../types/models/bank'; import { createApp } from '../app'; @@ -42,6 +44,7 @@ export type AccountHandlers = { '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; @@ -52,7 +55,9 @@ export type AccountHandlers = { '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; @@ -169,7 +174,7 @@ async function linkSimpleFinAccount({ let id; const institution = { - name: externalAccount.institution ?? 'Unknown', + name: externalAccount.institution ?? t('Unknown'), }; const bank = await link.findOrCreateBank( @@ -222,6 +227,70 @@ async function linkSimpleFinAccount({ 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('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: '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 createAccount({ name, balance = 0, @@ -540,6 +609,27 @@ async function simpleFinStatus() { ); } +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'); @@ -566,6 +656,32 @@ async function simpleFinAccounts() { } } +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'); @@ -1025,6 +1141,7 @@ 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('account-create', mutator(undoable(createAccount))); app.method('account-close', mutator(closeAccount)); app.method('account-reopen', mutator(undoable(reopenAccount))); @@ -1035,7 +1152,9 @@ 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); diff --git a/packages/loot-core/src/server/accounts/sync.ts b/packages/loot-core/src/server/accounts/sync.ts index dfe4ab4f879..514a6d7f95c 100644 --- a/packages/loot-core/src/server/accounts/sync.ts +++ b/packages/loot-core/src/server/accounts/sync.ts @@ -249,6 +249,45 @@ async function downloadSimpleFinTransactions( return retVal; } +async function downloadPluggyAiTransactions( + acctId: AccountEntity['id'], + since: string, +) { + const userToken = await asyncStorage.getItem('user-token'); + if (!userToken) return; + + console.log('Pulling transactions from Pluggy.ai'); + + const res = await post( + getServer().PLUGGYAI_SERVER + '/transactions', + { + accountId: acctId, + startDate: since, + }, + { + 'X-ACTUAL-TOKEN': userToken, + }, + 60000, + ); + + if (res.error_code) { + throw BankSyncError(res.error_type, res.error_code); + } else if ('error' in res) { + throw BankSyncError('Connection', res.error); + } + + let retVal = {}; + const singleRes = res as BankSyncResponse; + retVal = { + transactions: singleRes.transactions.all, + accountBalance: singleRes.balances, + startingBalance: singleRes.startingBalance, + }; + + console.log('Response:', retVal); + return retVal; +} + async function resolvePayee(trans, payeeName, payeesToCreate) { if (trans.payee == null && payeeName) { // First check our registry of new payees (to avoid a db access) @@ -808,6 +847,15 @@ async function processBankSyncDownload( balanceToUse = previousBalance; } + if (acctRow.account_sync_source === 'pluggyai') { + const currentBalance = download.startingBalance; + const previousBalance = transactions.reduce( + (total, trans) => total - trans.transactionAmount.amount * 100, + currentBalance, + ); + balanceToUse = Math.round(previousBalance); + } + const oldestTransaction = transactions[transactions.length - 1]; const oldestDate = @@ -882,6 +930,8 @@ export async function syncAccount( let download; if (acctRow.account_sync_source === 'simpleFin') { download = await downloadSimpleFinTransactions(acctId, syncStartDate); + } else if (acctRow.account_sync_source === 'pluggyai') { + download = await downloadPluggyAiTransactions(acctId, syncStartDate); } else if (acctRow.account_sync_source === 'goCardless') { download = await downloadGoCardlessTransactions( userId, diff --git a/packages/loot-core/src/server/server-config.ts b/packages/loot-core/src/server/server-config.ts index ddef5636a4e..2455af1ed1a 100644 --- a/packages/loot-core/src/server/server-config.ts +++ b/packages/loot-core/src/server/server-config.ts @@ -6,6 +6,7 @@ type ServerConfig = { SIGNUP_SERVER: string; GOCARDLESS_SERVER: string; SIMPLEFIN_SERVER: string; + PLUGGYAI_SERVER: string; }; let config: ServerConfig | null = null; @@ -42,6 +43,7 @@ export function getServer(url?: string): ServerConfig | null { SIGNUP_SERVER: joinURL(url, '/account'), GOCARDLESS_SERVER: joinURL(url, '/gocardless'), SIMPLEFIN_SERVER: joinURL(url, '/simplefin'), + PLUGGYAI_SERVER: joinURL(url, '/pluggyai'), }; } catch (error) { console.warn( diff --git a/packages/loot-core/src/types/models/bank-sync.d.ts b/packages/loot-core/src/types/models/bank-sync.d.ts index f0ab8a3758c..789c9c578c3 100644 --- a/packages/loot-core/src/types/models/bank-sync.d.ts +++ b/packages/loot-core/src/types/models/bank-sync.d.ts @@ -20,4 +20,4 @@ export type BankSyncResponse = { error_code: string; }; -export type BankSyncProviders = 'goCardless' | 'simpleFin'; +export type BankSyncProviders = 'goCardless' | 'simpleFin' | 'pluggyai'; diff --git a/packages/loot-core/src/types/models/index.d.ts b/packages/loot-core/src/types/models/index.d.ts index 543ca5eca11..fbe062a99b4 100644 --- a/packages/loot-core/src/types/models/index.d.ts +++ b/packages/loot-core/src/types/models/index.d.ts @@ -5,6 +5,7 @@ export type * from './category-group'; export type * from './dashboard'; export type * from './gocardless'; export type * from './simplefin'; +export type * from './pluggyai'; export type * from './note'; export type * from './payee'; export type * from './reports'; diff --git a/packages/loot-core/src/types/models/pluggyai.d.ts b/packages/loot-core/src/types/models/pluggyai.d.ts new file mode 100644 index 00000000000..5476bfcc01e --- /dev/null +++ b/packages/loot-core/src/types/models/pluggyai.d.ts @@ -0,0 +1,18 @@ +export type PluggyAiOrganization = { + name: string; + domain: string; +}; + +export type PluggyAiAccount = { + id: string; + name: string; + org: PluggyAiOrganization; +}; + +export type SyncServerPluggyAiAccount = { + account_id: string; + institution?: string; + orgDomain?: string; + orgId?: string; + name: string; +}; diff --git a/packages/loot-core/src/types/prefs.d.ts b/packages/loot-core/src/types/prefs.d.ts index fad8fc946d3..8b6dacc0f16 100644 --- a/packages/loot-core/src/types/prefs.d.ts +++ b/packages/loot-core/src/types/prefs.d.ts @@ -3,7 +3,8 @@ export type FeatureFlag = | 'goalTemplatesUIEnabled' | 'actionTemplating' | 'contextMenus' - | 'openidAuth'; + | 'openidAuth' + | 'pluggyAiBankSync'; /** * Cross-device preferences. These sync across devices when they are changed. diff --git a/packages/sync-server/package.json b/packages/sync-server/package.json index 13ed1a6511e..98f35c851e8 100644 --- a/packages/sync-server/package.json +++ b/packages/sync-server/package.json @@ -61,6 +61,7 @@ "http-proxy-middleware": "^3.0.3", "jest": "^29.3.1", "nodemon": "^3.1.9", + "pluggy-sdk": "^0.68.1", "prettier": "^2.8.3", "supertest": "^6.3.1", "typescript": "^4.9.5" diff --git a/packages/sync-server/src/app-pluggyai/app-pluggyai.js b/packages/sync-server/src/app-pluggyai/app-pluggyai.js new file mode 100644 index 00000000000..10715cc9905 --- /dev/null +++ b/packages/sync-server/src/app-pluggyai/app-pluggyai.js @@ -0,0 +1,206 @@ +import express from 'express'; + +import { handleError } from '../app-gocardless/util/handle-error.js'; +import { SecretName, secretsService } from '../services/secrets-service.js'; +import { requestLoggerMiddleware } from '../util/middlewares.js'; + +import { pluggyaiService } from './pluggyai-service.js'; + +const app = express(); +export { app as handlers }; +app.use(express.json()); +app.use(requestLoggerMiddleware); + +app.post( + '/status', + handleError(async (req, res) => { + const clientId = secretsService.get(SecretName.pluggyai_clientId); + const configured = clientId != null; + + res.send({ + status: 'ok', + data: { + configured, + }, + }); + }), +); + +app.post( + '/accounts', + handleError(async (req, res) => { + try { + const itemIds = secretsService + .get(SecretName.pluggyai_itemIds) + .split(',') + .map(item => item.trim()); + + let accounts = []; + + for (const item of itemIds) { + const partial = await pluggyaiService.getAccountsByItemId(item); + accounts = accounts.concat(partial.results); + } + + res.send({ + status: 'ok', + data: { + accounts, + }, + }); + } catch (error) { + res.send({ + status: 'ok', + data: { + error: error.message, + }, + }); + } + }), +); + +app.post( + '/transactions', + handleError(async (req, res) => { + const { accountId, startDate } = req.body; + + try { + const transactions = await pluggyaiService.getTransactions( + accountId, + startDate, + ); + + const account = await pluggyaiService.getAccountById(accountId); + + let startingBalance = parseInt( + Math.round(account.balance * 100).toString(), + ); + if (account.type === 'CREDIT') { + startingBalance = -startingBalance; + } + const date = getDate(new Date(account.updatedAt)); + + const balances = [ + { + balanceAmount: { + amount: startingBalance, + currency: account.currencyCode, + }, + balanceType: 'expected', + referenceDate: date, + }, + ]; + + const all = []; + const booked = []; + const pending = []; + + for (const trans of transactions) { + const newTrans = {}; + + newTrans.booked = !(trans.status === 'PENDING'); + + const transactionDate = new Date(trans.date); + + if (transactionDate < startDate && !trans.sandbox) { + continue; + } + + newTrans.date = getDate(transactionDate); + newTrans.payeeName = getPayeeName(trans); + newTrans.notes = trans.descriptionRaw || trans.description; + + let amountInCurrency = trans.amountInAccountCurrency ?? trans.amount; + amountInCurrency = Math.round(amountInCurrency * 100) / 100; + + newTrans.transactionAmount = { + amount: + account.type === 'BANK' ? amountInCurrency : -amountInCurrency, + currency: trans.currencyCode, + }; + + newTrans.transactionId = trans.id; + newTrans.sortOrder = transactionDate.getTime(); + + const finalTrans = { ...flattenObject(trans), ...newTrans }; + if (newTrans.booked) { + booked.push(finalTrans); + } else { + pending.push(finalTrans); + } + all.push(finalTrans); + } + + const sortFunction = (a, b) => b.sortOrder - a.sortOrder; + + const bookedSorted = booked.sort(sortFunction); + const pendingSorted = pending.sort(sortFunction); + const allSorted = all.sort(sortFunction); + + res.send({ + status: 'ok', + data: { + balances, + startingBalance, + transactions: { + all: allSorted, + booked: bookedSorted, + pending: pendingSorted, + }, + }, + }); + } catch (error) { + res.send({ + status: 'ok', + data: { + error: error.message, + }, + }); + } + return; + }), +); + +function getDate(date) { + return date.toISOString().split('T')[0]; +} + +function flattenObject(obj, prefix = '') { + const result = {}; + + for (const [key, value] of Object.entries(obj)) { + const newKey = prefix ? `${prefix}.${key}` : key; + + if (value === null) { + continue; + } + + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + Object.assign(result, flattenObject(value, newKey)); + } else { + result[newKey] = value; + } + } + + return result; +} + +function getPayeeName(trans) { + if (trans.merchant && (trans.merchant.name || trans.merchant.businessName)) { + return trans.merchant.name || trans.merchant.businessName || ''; + } + + if (trans.paymentData) { + const { receiver, payer } = trans.paymentData; + + if (trans.type === 'DEBIT' && receiver) { + return receiver.name || receiver.documentNumber?.value || ''; + } + + if (trans.type === 'CREDIT' && payer) { + return payer.name || payer.documentNumber?.value || ''; + } + } + + return ''; +} diff --git a/packages/sync-server/src/app-pluggyai/pluggyai-service.js b/packages/sync-server/src/app-pluggyai/pluggyai-service.js new file mode 100644 index 00000000000..4784e59b0b0 --- /dev/null +++ b/packages/sync-server/src/app-pluggyai/pluggyai-service.js @@ -0,0 +1,120 @@ +import { PluggyClient } from 'pluggy-sdk'; + +import { SecretName, secretsService } from '../services/secrets-service.js'; + +let pluggyClient = null; + +function getPluggyClient() { + if (!pluggyClient) { + const clientId = secretsService.get(SecretName.pluggyai_clientId); + const clientSecret = secretsService.get(SecretName.pluggyai_clientSecret); + + pluggyClient = new PluggyClient({ + clientId, + clientSecret, + }); + } + + return pluggyClient; +} + +export const pluggyaiService = { + isConfigured: () => { + return !!( + secretsService.get(SecretName.pluggyai_clientId) && + secretsService.get(SecretName.pluggyai_clientSecret) && + secretsService.get(SecretName.pluggyai_itemIds) + ); + }, + + getAccountsByItemId: async itemId => { + try { + const client = getPluggyClient(); + const { results, total, ...rest } = await client.fetchAccounts(itemId); + return { + results, + total, + ...rest, + hasError: false, + errors: {}, + }; + } catch (error) { + console.error(`Error fetching accounts: ${error.message}`); + throw error; + } + }, + getAccountById: async accountId => { + try { + const client = getPluggyClient(); + const account = await client.fetchAccount(accountId); + return { + ...account, + hasError: false, + errors: {}, + }; + } catch (error) { + console.error(`Error fetching account: ${error.message}`); + throw error; + } + }, + + getTransactionsByAccountId: async (accountId, startDate, pageSize, page) => { + try { + const client = getPluggyClient(); + + const account = await pluggyaiService.getAccountById(accountId); + + // the sandbox data doesn't move the dates automatically so the + // transactions are often older than 90 days. The owner on one of the + // sandbox accounts is set to John Doe so in these cases we'll ignore + // the start date. + const sandboxAccount = account.owner === 'John Doe'; + + if (sandboxAccount) startDate = '2000-01-01'; + + const transactions = await client.fetchTransactions(accountId, { + from: startDate, + pageSize, + page, + }); + + if (sandboxAccount) { + transactions.results = transactions.results.map(t => ({ + ...t, + sandbox: true, + })); + } + + return { + ...transactions, + hasError: false, + errors: {}, + }; + } catch (error) { + console.error(`Error fetching transactions: ${error.message}`); + throw error; + } + }, + getTransactions: async (accountId, startDate) => { + let transactions = []; + let result = await pluggyaiService.getTransactionsByAccountId( + accountId, + startDate, + 500, + 1, + ); + transactions = transactions.concat(result.results); + const totalPages = result.totalPages; + while (result.page !== totalPages) { + result = await pluggyaiService.getTransactionsByAccountId( + accountId, + startDate, + 500, + result.page + 1, + ); + transactions = transactions.concat(result.results); + } + + return transactions; + }, +}; diff --git a/packages/sync-server/src/app.js b/packages/sync-server/src/app.js index 73c258bb705..efa5ead6d69 100644 --- a/packages/sync-server/src/app.js +++ b/packages/sync-server/src/app.js @@ -10,6 +10,7 @@ import * as accountApp from './app-account.js'; import * as adminApp from './app-admin.js'; import * as goCardlessApp from './app-gocardless/app-gocardless.js'; import * as openidApp from './app-openid.js'; +import * as pluggai from './app-pluggyai/app-pluggyai.js'; import * as secretApp from './app-secrets.js'; import * as simpleFinApp from './app-simplefin/app-simplefin.js'; import * as syncApp from './app-sync.js'; @@ -53,6 +54,7 @@ app.use('/sync', syncApp.handlers); app.use('/account', accountApp.handlers); app.use('/gocardless', goCardlessApp.handlers); app.use('/simplefin', simpleFinApp.handlers); +app.use('/pluggyai', pluggai.handlers); app.use('/secret', secretApp.handlers); app.use('/admin', adminApp.handlers); diff --git a/packages/sync-server/src/services/secrets-service.js b/packages/sync-server/src/services/secrets-service.js index b78b8c610a7..82065262827 100644 --- a/packages/sync-server/src/services/secrets-service.js +++ b/packages/sync-server/src/services/secrets-service.js @@ -12,6 +12,9 @@ export const SecretName = { gocardless_secretKey: 'gocardless_secretKey', simplefin_token: 'simplefin_token', simplefin_accessKey: 'simplefin_accessKey', + pluggyai_clientId: 'pluggyai_clientId', + pluggyai_clientSecret: 'pluggyai_clientSecret', + pluggyai_itemIds: 'pluggyai_itemIds', }; class SecretsDb { diff --git a/upcoming-release-notes/4049.md b/upcoming-release-notes/4049.md new file mode 100644 index 00000000000..3c90d3d4797 --- /dev/null +++ b/upcoming-release-notes/4049.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [lelemm] +--- + +Add Pluggy.ai bank sync for Brazilian Banks diff --git a/yarn.lock b/yarn.lock index 928e1e1471a..152b05e5f9c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -117,6 +117,7 @@ __metadata: nodemon: "npm:^3.1.9" nordigen-node: "npm:^1.4.0" openid-client: "npm:^5.4.2" + pluggy-sdk: "npm:^0.68.1" prettier: "npm:^2.8.3" supertest: "npm:^6.3.1" typescript: "npm:^4.9.5" @@ -12963,7 +12964,7 @@ __metadata: languageName: node linkType: hard -"got@npm:^11.7.0, got@npm:^11.8.5": +"got@npm:11.8.6, got@npm:^11.7.0, got@npm:^11.8.5": version: 11.8.6 resolution: "got@npm:11.8.6" dependencies: @@ -15689,6 +15690,24 @@ __metadata: languageName: node linkType: hard +"jsonwebtoken@npm:^9.0.2": + version: 9.0.2 + resolution: "jsonwebtoken@npm:9.0.2" + dependencies: + jws: "npm:^3.2.2" + lodash.includes: "npm:^4.3.0" + lodash.isboolean: "npm:^3.0.3" + lodash.isinteger: "npm:^4.0.4" + lodash.isnumber: "npm:^3.0.3" + lodash.isplainobject: "npm:^4.0.6" + lodash.isstring: "npm:^4.0.1" + lodash.once: "npm:^4.0.0" + ms: "npm:^2.1.1" + semver: "npm:^7.5.4" + checksum: 10/6e9b6d879cec2b27f2f3a88a0c0973edc7ba956a5d9356b2626c4fddfda969e34a3832deaf79c3e1c6c9a525bc2c4f2c2447fa477f8ac660f0017c31a59ae96b + languageName: node + linkType: hard + "jsverify@npm:^0.8.4": version: 0.8.4 resolution: "jsverify@npm:0.8.4" @@ -15713,6 +15732,17 @@ __metadata: languageName: node linkType: hard +"jwa@npm:^1.4.1": + version: 1.4.1 + resolution: "jwa@npm:1.4.1" + dependencies: + buffer-equal-constant-time: "npm:1.0.1" + ecdsa-sig-formatter: "npm:1.0.11" + safe-buffer: "npm:^5.0.1" + checksum: 10/0bc002b71dd70480fedc7d442a4d2b9185a9947352a027dcb4935864ad2323c57b5d391adf968a3622b61e940cef4f3484d5813b95864539272d41cac145d6f3 + languageName: node + linkType: hard + "jwa@npm:^2.0.0": version: 2.0.0 resolution: "jwa@npm:2.0.0" @@ -15724,6 +15754,16 @@ __metadata: languageName: node linkType: hard +"jws@npm:^3.2.2": + version: 3.2.2 + resolution: "jws@npm:3.2.2" + dependencies: + jwa: "npm:^1.4.1" + safe-buffer: "npm:^5.0.1" + checksum: 10/70b016974af8a76d25030c80a0097b24ed5b17a9cf10f43b163c11cb4eb248d5d04a3fe48c0d724d2884c32879d878ccad7be0663720f46b464f662f7ed778fe + languageName: node + linkType: hard + "jws@npm:^4.0.0": version: 4.0.0 resolution: "jws@npm:4.0.0" @@ -15946,6 +15986,48 @@ __metadata: languageName: node linkType: hard +"lodash.includes@npm:^4.3.0": + version: 4.3.0 + resolution: "lodash.includes@npm:4.3.0" + checksum: 10/45e0a7c7838c931732cbfede6327da321b2b10482d5063ed21c020fa72b09ca3a4aa3bda4073906ab3f436cf36eb85a52ea3f08b7bab1e0baca8235b0e08fe51 + languageName: node + linkType: hard + +"lodash.isboolean@npm:^3.0.3": + version: 3.0.3 + resolution: "lodash.isboolean@npm:3.0.3" + checksum: 10/b70068b4a8b8837912b54052557b21fc4774174e3512ed3c5b94621e5aff5eb6c68089d0a386b7e801d679cd105d2e35417978a5e99071750aa2ed90bffd0250 + languageName: node + linkType: hard + +"lodash.isinteger@npm:^4.0.4": + version: 4.0.4 + resolution: "lodash.isinteger@npm:4.0.4" + checksum: 10/c971f5a2d67384f429892715550c67bac9f285604a0dd79275fd19fef7717aec7f2a6a33d60769686e436ceb9771fd95fe7fcb68ad030fc907d568d5a3b65f70 + languageName: node + linkType: hard + +"lodash.isnumber@npm:^3.0.3": + version: 3.0.3 + resolution: "lodash.isnumber@npm:3.0.3" + checksum: 10/913784275b565346255e6ae6a6e30b760a0da70abc29f3e1f409081585875105138cda4a429ff02577e1bc0a7ae2a90e0a3079a37f3a04c3d6c5aaa532f4cab2 + languageName: node + linkType: hard + +"lodash.isplainobject@npm:^4.0.6": + version: 4.0.6 + resolution: "lodash.isplainobject@npm:4.0.6" + checksum: 10/29c6351f281e0d9a1d58f1a4c8f4400924b4c79f18dfc4613624d7d54784df07efaff97c1ff2659f3e085ecf4fff493300adc4837553104cef2634110b0d5337 + languageName: node + linkType: hard + +"lodash.isstring@npm:^4.0.1": + version: 4.0.1 + resolution: "lodash.isstring@npm:4.0.1" + checksum: 10/eaac87ae9636848af08021083d796e2eea3d02e80082ab8a9955309569cb3a463ce97fd281d7dc119e402b2e7d8c54a23914b15d2fc7fff56461511dc8937ba0 + languageName: node + linkType: hard + "lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" @@ -15953,6 +16035,13 @@ __metadata: languageName: node linkType: hard +"lodash.once@npm:^4.0.0": + version: 4.1.1 + resolution: "lodash.once@npm:4.1.1" + checksum: 10/202f2c8c3d45e401b148a96de228e50ea6951ee5a9315ca5e15733d5a07a6b1a02d9da1e7fdf6950679e17e8ca8f7190ec33cae47beb249b0c50019d753f38f3 + languageName: node + linkType: hard + "lodash.sortby@npm:^4.7.0": version: 4.7.0 resolution: "lodash.sortby@npm:4.7.0" @@ -18363,6 +18452,16 @@ __metadata: languageName: node linkType: hard +"pluggy-sdk@npm:^0.68.1": + version: 0.68.1 + resolution: "pluggy-sdk@npm:0.68.1" + dependencies: + got: "npm:11.8.6" + jsonwebtoken: "npm:^9.0.2" + checksum: 10/1e36681d07ec134515e54f191a820c30fa242297f2cf2269de877be94dddee43de92af385e1f2f7a2db9d7b5e9db8e650fd02b7312dd72d18e10471e927905f9 + languageName: node + linkType: hard + "possible-typed-array-names@npm:^1.0.0": version: 1.0.0 resolution: "possible-typed-array-names@npm:1.0.0"