mirror of
https://github.com/actualbudget/actual.git
synced 2026-11-10 15:10:01 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -69,6 +69,9 @@ type FinanceModals = {
|
||||
'simplefin-init': {
|
||||
onSuccess: () => void;
|
||||
};
|
||||
'pluggyai-init': {
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
'gocardless-external-msg': {
|
||||
onMoveExternal: (arg: {
|
||||
|
||||
@@ -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
@@ -20,4 +20,4 @@ export type BankSyncResponse = {
|
||||
error_code: string;
|
||||
};
|
||||
|
||||
export type BankSyncProviders = 'goCardless' | 'simpleFin';
|
||||
export type BankSyncProviders = 'goCardless' | 'simpleFin' | 'pluggyai';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -3,7 +3,8 @@ export type FeatureFlag =
|
||||
| 'goalTemplatesUIEnabled'
|
||||
| 'actionTemplating'
|
||||
| 'contextMenus'
|
||||
| 'openidAuth';
|
||||
| 'openidAuth'
|
||||
| 'pluggyAiBankSync';
|
||||
|
||||
/**
|
||||
* Cross-device preferences. These sync across devices when they are changed.
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [lelemm]
|
||||
---
|
||||
|
||||
Add Pluggy.ai bank sync for Brazilian Banks
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user