Pluggy.ai bank sync (#4049)

* added Pluggy.ai bank sync

* added md

* code review nits

* small fixes

* fix syncs

* refactory after redux changes

* changed trunc to round

* removed debugger

* linter

* linter again

* sync-server changes

* types

* code review

* typecheck

* fixes

* removed old sync server file

* code review

* added more fields to mapping

* linter

* code review

* Update packages/sync-server/src/app-pluggyai/app-pluggyai.js

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>

* code review

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
This commit is contained in:
lelemm
2025-03-04 08:07:58 -03:00
committed by GitHub
co-authored by Matt Fiddaman
parent f9b8dde199
commit d5e2030d23
25 changed files with 1103 additions and 6 deletions
@@ -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 (
<PluggyAiInitialiseModal key={name} onSuccess={options.onSuccess} />
);
case 'gocardless-external-msg':
return (
<GoCardlessExternalMsgModal
@@ -62,6 +62,15 @@ const mappableFields: MappableField[] = [
'remittanceInformationStructured',
'remittanceInformationStructuredArrayString',
'additionalInformation',
'paymentData.payer.accountNumber',
'paymentData.payer.documentNumber.value',
'paymentData.payer.name',
'paymentData.receiver.accountNumber',
'paymentData.receiver.documentNumber.value',
'paymentData.receiver.name',
'merchant.name',
'merchant.businessName',
'merchant.cnpj',
],
},
{
@@ -73,6 +82,16 @@ const mappableFields: MappableField[] = [
'remittanceInformationStructured',
'remittanceInformationStructuredArrayString',
'additionalInformation',
'category',
'paymentData.payer.accountNumber',
'paymentData.payer.documentNumber.value',
'paymentData.payer.name',
'paymentData.receiver.accountNumber',
'paymentData.receiver.documentNumber.value',
'paymentData.receiver.name',
'merchant.name',
'merchant.businessName',
'merchant.cnpj',
],
},
];
@@ -28,6 +28,7 @@ const useSyncSourceReadable = () => {
const syncSourceReadable: Record<SyncProviders, string> = {
goCardless: 'GoCardless',
simpleFin: 'SimpleFIN',
pluggyai: 'Pluggy.ai',
unlinked: t('Unlinked'),
};
@@ -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.
</Trans>
</Text>
{isPluggyAiEnabled && (
<>
<View
style={{
flexDirection: 'row',
gap: 10,
alignItems: 'center',
}}
>
<ButtonWithLoading
isDisabled={syncServerStatus !== 'online'}
style={{
padding: '10px 0',
fontSize: 15,
fontWeight: 600,
flex: 1,
}}
onPress={onConnectPluggyAi}
>
{isPluggyAiSetupComplete
? t('Link bank account with Pluggy.ai')
: t('Set up Pluggy.ai for bank sync')}
</ButtonWithLoading>
{isPluggyAiSetupComplete && (
<DialogTrigger>
<Button
variant="bare"
aria-label={t('Pluggy.ai menu')}
>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Button>
<Popover>
<Menu
onMenuSelect={item => {
if (item === 'reconfigure') {
onPluggyAiReset();
}
}}
items={[
{
name: 'reconfigure',
text: t('Reset Pluggy.ai credentials'),
},
]}
/>
</Popover>
</DialogTrigger>
)}
</View>
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
<Trans>
<strong>
Link a <em>Brazilian</em> bank account
</strong>{' '}
to automatically download transactions. Pluggy.ai
provides reliable, up-to-date information from
hundreds of banks.
</Trans>
</Text>
</>
)}
</>
)}
{(!isGoCardlessSetupComplete || !isSimpleFinSetupComplete) &&
{(!isGoCardlessSetupComplete ||
!isSimpleFinSetupComplete ||
!isPluggyAiSetupComplete) &&
!canSetSecrets && (
<Warning>
<Trans>
@@ -371,6 +542,7 @@ export function CreateAccountModal({ upgradingAccountId }: CreateAccountProps) {
{[
isGoCardlessSetupComplete ? '' : 'GoCardless',
isSimpleFinSetupComplete ? '' : 'SimpleFin',
isPluggyAiSetupComplete ? '' : 'Pluggy.ai',
]
.filter(Boolean)
.join(' or ')}
@@ -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 (
<Modal name="pluggyai-init" containerProps={{ style: { width: '30vw' } }}>
{({ state: { close } }) => (
<>
<ModalHeader
title={t('Set-up Pluggy.ai')}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ display: 'flex', gap: 10 }}>
<Text>
<Trans>
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{' '}
<Link
variant="external"
to="https://actualbudget.org/docs/advanced/bank-sync/"
linkColor="purple"
>
Pluggy.ai
</Link>
.
</Trans>
</Text>
<FormField>
<FormLabel title={t('Client ID:')} htmlFor="client-id-field" />
<InitialFocus>
<Input
id="client-id-field"
type="text"
value={clientId}
onChangeValue={value => {
setClientId(value);
setIsValid(true);
}}
/>
</InitialFocus>
</FormField>
<FormField>
<FormLabel
title={t('Client Secret:')}
htmlFor="client-secret-field"
/>
<Input
id="client-secret-field"
type="password"
value={clientSecret}
onChangeValue={value => {
setClientSecret(value);
setIsValid(true);
}}
/>
</FormField>
<FormField>
<FormLabel
title={t('Item Ids (comma separated):')}
htmlFor="item-ids-field"
/>
<Input
id="item-ids-field"
type="text"
value={itemIds}
placeholder="78a3db91-2b6f-4f33-914f-0c5f29c5e6b1, 47cdfe32-bef9-4b82-9ea5-41b89f207749"
onChangeValue={value => {
setItemIds(value);
setIsValid(true);
}}
/>
</FormField>
{!isValid && <Error>{error}</Error>}
</View>
<ModalButtons>
<ButtonWithLoading
variant="primary"
isLoading={isLoading}
onPress={() => {
onSubmit(close);
}}
>
<Trans>Save and continue</Trans>
</ButtonWithLoading>
</ModalButtons>
</>
)}
</Modal>
);
};
@@ -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({
@@ -111,6 +111,12 @@ export function ExperimentalFeatures() {
>
<Trans>OpenID authentication method</Trans>
</FeatureToggle>
<FeatureToggle
flag="pluggyAiBankSync"
feedbackLink="https://github.com/actualbudget/actual/pull/4049"
>
<Trans>Pluggy.ai Bank Sync (Brazilian banks only)</Trans>
</FeatureToggle>
</View>
) : (
<Link
@@ -8,6 +8,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
actionTemplating: false,
contextMenus: false,
openidAuth: false,
pluggyAiBankSync: false,
};
export function useFeatureFlag(name: FeatureFlag): boolean {
@@ -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<boolean | null>(
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,
};
}
@@ -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,
+3
View File
@@ -69,6 +69,9 @@ type FinanceModals = {
'simplefin-init': {
onSuccess: () => void;
};
'pluggyai-init': {
onSuccess: () => void;
};
'gocardless-external-msg': {
onMoveExternal: (arg: {
+120 -1
View File
@@ -1,3 +1,4 @@
import { t } from 'i18next';
import { v4 as uuidv4 } from 'uuid';
import { captureException } from '../../platform/exceptions';
@@ -13,6 +14,7 @@ import {
SyncServerGoCardlessAccount,
TransactionEntity,
SyncServerSimpleFinAccount,
SyncServerPluggyAiAccount,
} from '../../types/models';
import { createApp } from '../app';
import * as db from '../db';
@@ -40,6 +42,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;
@@ -50,7 +53,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;
@@ -184,7 +189,7 @@ async function linkSimpleFinAccount({
let id;
const institution = {
name: externalAccount.institution ?? 'Unknown',
name: externalAccount.institution ?? t('Unknown'),
};
const bank = await link.findOrCreateBank(
@@ -242,6 +247,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,
@@ -570,6 +639,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');
@@ -596,6 +686,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');
@@ -1068,6 +1184,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)));
@@ -1078,7 +1195,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);
@@ -256,6 +256,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)
@@ -815,6 +854,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 =
@@ -889,6 +937,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,
@@ -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(
+1 -1
View File
@@ -20,4 +20,4 @@ export type BankSyncResponse = {
error_code: string;
};
export type BankSyncProviders = 'goCardless' | 'simpleFin';
export type BankSyncProviders = 'goCardless' | 'simpleFin' | 'pluggyai';
+1
View File
@@ -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';
+18
View File
@@ -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;
};
+2 -1
View File
@@ -3,7 +3,8 @@ export type FeatureFlag =
| 'goalTemplatesUIEnabled'
| 'actionTemplating'
| 'contextMenus'
| 'openidAuth';
| 'openidAuth'
| 'pluggyAiBankSync';
/**
* Cross-device preferences. These sync across devices when they are changed.
+1
View File
@@ -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"
@@ -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 '';
}
@@ -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;
},
};
+2
View File
@@ -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);
@@ -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 {
+6
View File
@@ -0,0 +1,6 @@
---
category: Features
authors: [lelemm]
---
Add Pluggy.ai bank sync for Brazilian Banks
+100 -1
View File
@@ -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"
@@ -12974,7 +12975,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:
@@ -15700,6 +15701,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"
@@ -15724,6 +15743,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"
@@ -15735,6 +15765,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"
@@ -15957,6 +15997,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"
@@ -15964,6 +16046,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"
@@ -18374,6 +18463,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"