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 && (
+
+
+
+
+
+
+ )}
+
+
+
+
+ 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"