mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-06 07:01:45 -05:00
Compare commits
18 Commits
matiss/crd
...
feature/en
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beee8ee518 | ||
|
|
744cba7a0a | ||
|
|
1f379b6e4c | ||
|
|
f36a8880bf | ||
|
|
327469411a | ||
|
|
31893074a6 | ||
|
|
0cafb4acbc | ||
|
|
e8b6366816 | ||
|
|
e4b9d9c94e | ||
|
|
9403f57e6f | ||
|
|
5661ab7a6f | ||
|
|
8658b889ec | ||
|
|
cc544222da | ||
|
|
b13d04cc4a | ||
|
|
dc62a6aff7 | ||
|
|
1cbe1efbf4 | ||
|
|
33619dfc1d | ||
|
|
d8863a8d16 |
@@ -22,6 +22,7 @@
|
||||
"#transactions": "./src/transactions/index.ts",
|
||||
"#undo": "./src/undo/index.ts",
|
||||
"#global-events": "./src/global-events.ts",
|
||||
"#enablebanking": "./src/enablebanking.ts",
|
||||
"#gocardless": "./src/gocardless.ts",
|
||||
"#i18n": "./src/i18n.ts",
|
||||
"#mocks": "./src/mocks.tsx",
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SyncResponseWithErrors } from '@actual-app/core/server/accounts/ap
|
||||
import type {
|
||||
AccountEntity,
|
||||
CategoryEntity,
|
||||
SyncServerEnableBankingAccount,
|
||||
SyncServerGoCardlessAccount,
|
||||
SyncServerPluggyAiAccount,
|
||||
SyncServerSimpleFinAccount,
|
||||
@@ -499,6 +500,48 @@ export function useLinkAccountPluggyAiMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
type LinkAccountEnableBankingPayload = LinkAccountBasePayload & {
|
||||
externalAccount: SyncServerEnableBankingAccount;
|
||||
};
|
||||
|
||||
export function useLinkAccountEnableBankingMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
}: LinkAccountEnableBankingPayload) => {
|
||||
await send('enablebanking-accounts-link', {
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateQueries(queryClient);
|
||||
invalidateQueries(queryClient, payeeQueries.lists());
|
||||
},
|
||||
onError: error => {
|
||||
console.error('Error linking account to Enable Banking:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t(
|
||||
'There was an error linking the account to Enable Banking. Please try again.',
|
||||
),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type SyncAccountsPayload = {
|
||||
id?: AccountEntity['id'] | undefined;
|
||||
};
|
||||
|
||||
121
packages/desktop-client/src/components/EnableBankingCallback.tsx
Normal file
121
packages/desktop-client/src/components/EnableBankingCallback.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Paragraph } from '@actual-app/components/paragraph';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { send } from '@actual-app/core/platform/client/connection';
|
||||
|
||||
import { Error as ErrorAlert } from '#components/alerts';
|
||||
import { useUrlParam } from '#hooks/useUrlParam';
|
||||
|
||||
export function EnableBankingCallback() {
|
||||
const { t } = useTranslation();
|
||||
const [code] = useUrlParam('code');
|
||||
const [stateParam] = useUrlParam('state');
|
||||
const [errorParam] = useUrlParam('error');
|
||||
const storedState = localStorage.getItem('enablebanking_auth_state');
|
||||
const stateValid = !stateParam || !storedState || stateParam === storedState;
|
||||
const state = stateValid ? stateParam || storedState : null;
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>(
|
||||
'loading',
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const calledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (calledRef.current) return;
|
||||
calledRef.current = true;
|
||||
|
||||
async function handleCallback() {
|
||||
if (errorParam) {
|
||||
setStatus('error');
|
||||
setErrorMessage(
|
||||
t('Authorization was denied or failed: {{error}}', {
|
||||
error: errorParam,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('Missing authorization parameters.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stateValid) {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('Authorization state mismatch. Please try again.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('Missing authorization state. Please try again.'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await send('enablebanking-complete-auth', {
|
||||
code,
|
||||
state,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setStatus('error');
|
||||
setErrorMessage(
|
||||
result.error.message || t('Failed to complete authorization.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('success');
|
||||
localStorage.removeItem('enablebanking_auth_state');
|
||||
|
||||
// Auto-close after a short delay
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
}, 1500);
|
||||
} catch {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('An unexpected error occurred.'));
|
||||
}
|
||||
}
|
||||
|
||||
void handleCallback();
|
||||
}, [code, state, stateParam, stateValid, errorParam, t]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
padding: 20,
|
||||
maxWidth: 500,
|
||||
margin: '40px auto',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{status === 'loading' && (
|
||||
<Paragraph>
|
||||
<Trans>Completing authorization...</Trans>
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<Paragraph>
|
||||
<Trans>
|
||||
Authorization successful! This window will close automatically.
|
||||
</Trans>
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<ErrorAlert>{errorMessage}</ErrorAlert>
|
||||
<Paragraph style={{ marginTop: 10 }}>
|
||||
<Trans>You can close this window and try again.</Trans>
|
||||
</Paragraph>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { useDispatch, useSelector } from '#redux';
|
||||
import { UserAccessPage } from './admin/UserAccess/UserAccessPage';
|
||||
import { BankSyncStatus } from './BankSyncStatus';
|
||||
import { CommandBar } from './CommandBar';
|
||||
import { EnableBankingCallback } from './EnableBankingCallback';
|
||||
import { GlobalKeys } from './GlobalKeys';
|
||||
import { MobileBankSyncAccountEditPage } from './mobile/banksync/MobileBankSyncAccountEditPage';
|
||||
import { MobileNavTabs } from './mobile/MobileNavTabs';
|
||||
@@ -316,6 +317,11 @@ export function FinancesApp() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/enablebanking/auth_callback"
|
||||
element={<EnableBankingCallback />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/accounts"
|
||||
element={<NarrowAlternate name="Accounts" />}
|
||||
|
||||
@@ -36,6 +36,8 @@ import { EditUserAccess } from './modals/EditAccess';
|
||||
import { EditFieldModal } from './modals/EditFieldModal';
|
||||
import { EditRuleModal } from './modals/EditRuleModal';
|
||||
import { EditUserFinanceApp } from './modals/EditUser';
|
||||
import { EnableBankingExternalMsgModal } from './modals/EnableBankingExternalMsgModal';
|
||||
import { EnableBankingInitialiseModal } from './modals/EnableBankingInitialiseModal';
|
||||
import { EnvelopeBalanceMenuModal } from './modals/EnvelopeBalanceMenuModal';
|
||||
import { EnvelopeBudgetMenuModal } from './modals/EnvelopeBudgetMenuModal';
|
||||
import { EnvelopeBudgetMonthMenuModal } from './modals/EnvelopeBudgetMonthMenuModal';
|
||||
@@ -187,6 +189,21 @@ export function Modals() {
|
||||
case 'pluggyai-init':
|
||||
return <PluggyAiInitialiseModal key={key} {...modal.options} />;
|
||||
|
||||
case 'enablebanking-init':
|
||||
return <EnableBankingInitialiseModal key={key} {...modal.options} />;
|
||||
|
||||
case 'enablebanking-external-msg':
|
||||
return (
|
||||
<EnableBankingExternalMsgModal
|
||||
key={key}
|
||||
{...modal.options}
|
||||
onClose={() => {
|
||||
modal.options.onClose?.();
|
||||
void send('enablebanking-poll-auth-stop');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'gocardless-external-msg':
|
||||
return (
|
||||
<GoCardlessExternalMsgModal
|
||||
|
||||
@@ -11,7 +11,8 @@ import type { AccountEntity } from '@actual-app/core/types/models';
|
||||
|
||||
import { useUnlinkAccountMutation } from '#accounts';
|
||||
import { Link } from '#components/common/Link';
|
||||
import { authorizeBank } from '#gocardless';
|
||||
import { authorizeBank as authorizeEnableBanking } from '#enablebanking';
|
||||
import { authorizeBank as authorizeGoCardless } from '#gocardless';
|
||||
import { useAccounts } from '#hooks/useAccounts';
|
||||
import { useFailedAccounts } from '#hooks/useFailedAccounts';
|
||||
import { useDispatch } from '#redux';
|
||||
@@ -103,7 +104,11 @@ export function AccountSyncCheck() {
|
||||
setOpen(false);
|
||||
|
||||
if (acc.account_id) {
|
||||
void authorizeBank(dispatch);
|
||||
if (acc.account_sync_source === 'enableBanking') {
|
||||
void authorizeEnableBanking(dispatch);
|
||||
} else if (acc.account_sync_source === 'goCardless') {
|
||||
void authorizeGoCardless(dispatch);
|
||||
}
|
||||
}
|
||||
},
|
||||
[dispatch],
|
||||
|
||||
@@ -29,6 +29,7 @@ const useSyncSourceReadable = () => {
|
||||
goCardless: 'GoCardless',
|
||||
simpleFin: 'SimpleFIN',
|
||||
pluggyai: 'Pluggy.ai',
|
||||
enableBanking: 'Enable Banking',
|
||||
unlinked: t('Unlinked'),
|
||||
};
|
||||
|
||||
|
||||
@@ -3,13 +3,16 @@ import { Trans } from 'react-i18next';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import type { AccountEntity } from '@actual-app/core/types/models';
|
||||
import type {
|
||||
AccountEntity,
|
||||
BankSyncProviders,
|
||||
} from '@actual-app/core/types/models';
|
||||
|
||||
import { MOBILE_NAV_HEIGHT } from '#components/mobile/MobileNavTabs';
|
||||
|
||||
import { BankSyncAccountsListItem } from './BankSyncAccountsListItem';
|
||||
|
||||
type SyncProviders = 'goCardless' | 'simpleFin' | 'pluggyai' | 'unlinked';
|
||||
type SyncProviders = BankSyncProviders | 'unlinked';
|
||||
|
||||
type BankSyncAccountsListProps = {
|
||||
groupedAccounts: Record<SyncProviders, AccountEntity[]>;
|
||||
|
||||
@@ -28,6 +28,7 @@ const useSyncSourceReadable = () => {
|
||||
goCardless: 'GoCardless',
|
||||
simpleFin: 'SimpleFIN',
|
||||
pluggyai: 'Pluggy.ai',
|
||||
enableBanking: 'Enable Banking',
|
||||
unlinked: t('Unlinked'),
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,10 @@ import { Warning } from '#components/alerts';
|
||||
import { Link } from '#components/common/Link';
|
||||
import { Modal, ModalCloseButton, ModalHeader } from '#components/common/Modal';
|
||||
import { useMultiuserEnabled } from '#components/ServerContext';
|
||||
import { authorizeBank as authorizeEnableBanking } from '#enablebanking';
|
||||
import { authorizeBank } from '#gocardless';
|
||||
import { useEnableBankingStatus } from '#hooks/useEnableBankingStatus';
|
||||
import { useFeatureFlag } from '#hooks/useFeatureFlag';
|
||||
import { useGoCardlessStatus } from '#hooks/useGoCardlessStatus';
|
||||
import { usePluggyAiStatus } from '#hooks/usePluggyAiStatus';
|
||||
import { useSimpleFinStatus } from '#hooks/useSimpleFinStatus';
|
||||
@@ -50,6 +53,9 @@ export function CreateAccountModal({
|
||||
const [isPluggyAiSetupComplete, setIsPluggyAiSetupComplete] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
const [isEnableBankingSetupComplete, setIsEnableBankingSetupComplete] =
|
||||
useState<boolean | null>(null);
|
||||
const enableBankingEnabled = useFeatureFlag('enableBanking');
|
||||
const { hasPermission } = useAuth();
|
||||
const multiuserEnabled = useMultiuserEnabled();
|
||||
|
||||
@@ -212,6 +218,39 @@ export function CreateAccountModal({
|
||||
}
|
||||
};
|
||||
|
||||
const onConnectEnableBanking = async () => {
|
||||
if (isEnableBankingSetupComplete === false) {
|
||||
onEnableBankingInit();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authorizeEnableBanking(dispatch);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
title: t('Error when trying to contact Enable Banking'),
|
||||
message: (err as Error).message,
|
||||
timeout: 5000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'enablebanking-init',
|
||||
options: {
|
||||
onSuccess: () => setIsEnableBankingSetupComplete(true),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onGoCardlessInit = () => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
@@ -251,6 +290,33 @@ export function CreateAccountModal({
|
||||
);
|
||||
};
|
||||
|
||||
const onEnableBankingInit = () => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'enablebanking-init',
|
||||
options: {
|
||||
onSuccess: () => setIsEnableBankingSetupComplete(true),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onEnableBankingReset = () => {
|
||||
void send('secret-set', {
|
||||
name: 'enablebanking_applicationId',
|
||||
value: null,
|
||||
}).then(() => {
|
||||
void send('secret-set', {
|
||||
name: 'enablebanking_secretKey',
|
||||
value: null,
|
||||
}).then(() => {
|
||||
setIsEnableBankingSetupComplete(false);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const onGoCardlessReset = () => {
|
||||
void send('secret-set', {
|
||||
name: 'gocardless_secretId',
|
||||
@@ -317,6 +383,12 @@ export function CreateAccountModal({
|
||||
setIsPluggyAiSetupComplete(configuredPluggyAi);
|
||||
}, [configuredPluggyAi]);
|
||||
|
||||
const { configuredEnableBanking, isLoading: isEnableBankingLoading } =
|
||||
useEnableBankingStatus(enableBankingEnabled);
|
||||
useEffect(() => {
|
||||
setIsEnableBankingSetupComplete(configuredEnableBanking);
|
||||
}, [configuredEnableBanking]);
|
||||
|
||||
let title = t('Add account');
|
||||
const [loadingSimpleFinAccounts, setLoadingSimpleFinAccounts] =
|
||||
useState(false);
|
||||
@@ -569,12 +641,89 @@ export function CreateAccountModal({
|
||||
hundreds of banks.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
{enableBankingEnabled && (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
marginTop: '18px',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ButtonWithLoading
|
||||
isDisabled={
|
||||
syncServerStatus !== 'online' ||
|
||||
isEnableBankingLoading
|
||||
}
|
||||
isLoading={isEnableBankingLoading}
|
||||
style={{
|
||||
padding: '10px 0',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
flex: 1,
|
||||
}}
|
||||
onPress={onConnectEnableBanking}
|
||||
>
|
||||
{isEnableBankingSetupComplete
|
||||
? t('Link bank account with Enable Banking')
|
||||
: t('Set up Enable Banking for bank sync')}
|
||||
</ButtonWithLoading>
|
||||
{isEnableBankingSetupComplete && (
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('Enable Banking menu')}
|
||||
>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
height={15}
|
||||
style={{ transform: 'rotateZ(90deg)' }}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Popover>
|
||||
<Dialog>
|
||||
<Menu
|
||||
onMenuSelect={item => {
|
||||
if (item === 'reconfigure') {
|
||||
onEnableBankingReset();
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
name: 'reconfigure',
|
||||
text: t(
|
||||
'Reset Enable Banking credentials',
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
</View>
|
||||
<Text style={{ lineHeight: '1.4em', fontSize: 15 }}>
|
||||
<Trans>
|
||||
<strong>
|
||||
Link a <em>European</em> bank account
|
||||
</strong>{' '}
|
||||
via Enable Banking. A free alternative to
|
||||
GoCardless for PSD2-supported banks.
|
||||
</Trans>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(!isGoCardlessSetupComplete ||
|
||||
!isSimpleFinSetupComplete ||
|
||||
!isPluggyAiSetupComplete) &&
|
||||
!isPluggyAiSetupComplete ||
|
||||
(enableBankingEnabled &&
|
||||
isEnableBankingSetupComplete === false)) &&
|
||||
!canSetSecrets && (
|
||||
<Warning>
|
||||
<Trans>
|
||||
@@ -585,6 +734,10 @@ export function CreateAccountModal({
|
||||
isGoCardlessSetupComplete ? '' : 'GoCardless',
|
||||
isSimpleFinSetupComplete ? '' : 'SimpleFIN',
|
||||
isPluggyAiSetupComplete ? '' : 'Pluggy.ai',
|
||||
enableBankingEnabled &&
|
||||
isEnableBankingSetupComplete === false
|
||||
? t('Enable Banking')
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' or ')}
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
|
||||
import { Paragraph } from '@actual-app/components/paragraph';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { sendCatch } from '@actual-app/core/platform/client/connection';
|
||||
import type {
|
||||
EnableBankingAspsp,
|
||||
SyncServerEnableBankingAccount,
|
||||
} from '@actual-app/core/types/models';
|
||||
|
||||
import { Error, Warning } from '#components/alerts';
|
||||
import { Autocomplete } from '#components/autocomplete/Autocomplete';
|
||||
import { Link } from '#components/common/Link';
|
||||
import { Modal, ModalCloseButton, ModalHeader } from '#components/common/Modal';
|
||||
import { FormField, FormLabel } from '#components/forms';
|
||||
import { COUNTRY_OPTIONS } from '#components/util/countries';
|
||||
import { getCountryFromBrowser } from '#components/util/localeToCountry';
|
||||
import { useEnableBankingStatus } from '#hooks/useEnableBankingStatus';
|
||||
import { useGlobalPref } from '#hooks/useGlobalPref';
|
||||
import { pushModal } from '#modals/modalsSlice';
|
||||
import type { Modal as ModalType } from '#modals/modalsSlice';
|
||||
import { useDispatch } from '#redux';
|
||||
|
||||
type BankOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
maxConsentValidity?: number;
|
||||
};
|
||||
|
||||
function useAvailableBanks(country: string, refetchKey?: boolean | null) {
|
||||
const [banks, setBanks] = useState<BankOption[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetch() {
|
||||
setIsError(false);
|
||||
|
||||
if (!country) {
|
||||
setBanks([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const { data, error } = await sendCatch(
|
||||
'enablebanking-aspsps',
|
||||
country.toUpperCase(),
|
||||
);
|
||||
|
||||
if (error) {
|
||||
setIsError(true);
|
||||
setBanks([]);
|
||||
} else {
|
||||
const aspsps: EnableBankingAspsp[] = data?.aspsps ?? [];
|
||||
setBanks(
|
||||
aspsps.map(aspsp => ({
|
||||
id: `${aspsp.country}:${aspsp.name}`,
|
||||
name: aspsp.beta ? `${aspsp.name} (beta)` : aspsp.name,
|
||||
maxConsentValidity: aspsp.maximum_consent_validity,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
void fetch();
|
||||
}, [setBanks, setIsLoading, country, refetchKey]);
|
||||
|
||||
return {
|
||||
data: banks,
|
||||
isLoading,
|
||||
isError,
|
||||
};
|
||||
}
|
||||
|
||||
function renderError(
|
||||
error: { code: 'unknown' | 'timeout'; message?: string },
|
||||
t: ReturnType<typeof useTranslation>['t'],
|
||||
) {
|
||||
return (
|
||||
<Error style={{ alignSelf: 'center', marginBottom: 10 }}>
|
||||
{error.code === 'timeout'
|
||||
? t('Timed out. Please try again.')
|
||||
: t(
|
||||
'An error occurred while linking your account, sorry! The potential issue could be: {{ message }}',
|
||||
{ message: error.message },
|
||||
)}
|
||||
</Error>
|
||||
);
|
||||
}
|
||||
|
||||
type EnableBankingExternalMsgModalProps = Extract<
|
||||
ModalType,
|
||||
{ name: 'enablebanking-external-msg' }
|
||||
>['options'];
|
||||
|
||||
export function EnableBankingExternalMsgModal({
|
||||
onMoveExternal,
|
||||
onSuccess,
|
||||
onClose,
|
||||
}: EnableBankingExternalMsgModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [language] = useGlobalPref('language');
|
||||
|
||||
const browserTimezone =
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||
const browserLocale = language || navigator.language || 'en-US';
|
||||
const detectedCountry = getCountryFromBrowser(
|
||||
browserTimezone,
|
||||
browserLocale,
|
||||
COUNTRY_OPTIONS,
|
||||
);
|
||||
|
||||
const [waiting, setWaiting] = useState<string | null>(null);
|
||||
const [selectedAspsp, setSelectedAspsp] = useState<string>();
|
||||
const [country, setCountry] = useState<string | undefined>(detectedCountry);
|
||||
const [error, setError] = useState<{
|
||||
code: 'unknown' | 'timeout';
|
||||
message?: string;
|
||||
} | null>(null);
|
||||
const [isEnableBankingSetupComplete, setIsEnableBankingSetupComplete] =
|
||||
useState<boolean | null>(null);
|
||||
const data = useRef<{ accounts: SyncServerEnableBankingAccount[] } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const {
|
||||
data: bankOptions,
|
||||
isLoading: isBankOptionsLoading,
|
||||
isError: isBankOptionError,
|
||||
} = useAvailableBanks(country, isEnableBankingSetupComplete);
|
||||
const {
|
||||
configuredEnableBanking: isConfigured,
|
||||
isLoading: isConfigurationLoading,
|
||||
} = useEnableBankingStatus();
|
||||
|
||||
const isJumpingRef = useRef(false);
|
||||
|
||||
async function onJump() {
|
||||
if (isJumpingRef.current) {
|
||||
// Abort the in-flight poll so the user can retry
|
||||
await sendCatch('enablebanking-poll-auth-stop');
|
||||
return;
|
||||
}
|
||||
isJumpingRef.current = true;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setWaiting('browser');
|
||||
|
||||
// Parse aspspId (name) and country from the composite id "country:name"
|
||||
const colonIndex = selectedAspsp.indexOf(':');
|
||||
const aspspCountry = selectedAspsp.slice(0, colonIndex);
|
||||
const aspspId = selectedAspsp.slice(colonIndex + 1);
|
||||
|
||||
const selectedBank = bankOptions.find(b => b.id === selectedAspsp);
|
||||
|
||||
const res = await onMoveExternal({
|
||||
aspspId,
|
||||
country: aspspCountry,
|
||||
maxConsentValidity: selectedBank?.maxConsentValidity,
|
||||
});
|
||||
if ('error' in res) {
|
||||
setError({
|
||||
code: res.error,
|
||||
message: 'message' in res ? res.message : undefined,
|
||||
});
|
||||
setWaiting(null);
|
||||
return;
|
||||
}
|
||||
|
||||
data.current = res.data;
|
||||
setWaiting('accounts');
|
||||
await onSuccess(data.current);
|
||||
setWaiting(null);
|
||||
} finally {
|
||||
isJumpingRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
const onEnableBankingInit = () => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'enablebanking-init',
|
||||
options: {
|
||||
onSuccess: () => setIsEnableBankingSetupComplete(true),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const renderLinkButton = () => {
|
||||
return (
|
||||
<View style={{ gap: 10 }}>
|
||||
<FormField>
|
||||
<FormLabel
|
||||
title={t('Choose your country:')}
|
||||
htmlFor="country-field"
|
||||
/>
|
||||
<Autocomplete
|
||||
strict
|
||||
highlightFirst
|
||||
suggestions={COUNTRY_OPTIONS}
|
||||
onSelect={value => {
|
||||
setCountry(value);
|
||||
setSelectedAspsp(undefined);
|
||||
}}
|
||||
value={country}
|
||||
inputProps={{
|
||||
id: 'country-field',
|
||||
placeholder: t('(please select)'),
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{isBankOptionError ? (
|
||||
<Error>
|
||||
<Trans>
|
||||
Failed loading available banks: Enable Banking access credentials
|
||||
might be misconfigured. Please{' '}
|
||||
<Link
|
||||
variant="text"
|
||||
onClick={onEnableBankingInit}
|
||||
style={{ color: theme.formLabelText, display: 'inline' }}
|
||||
>
|
||||
set them up
|
||||
</Link>{' '}
|
||||
again.
|
||||
</Trans>
|
||||
</Error>
|
||||
) : (
|
||||
country &&
|
||||
(isBankOptionsLoading ? (
|
||||
t('Loading banks...')
|
||||
) : (
|
||||
<FormField>
|
||||
<FormLabel title={t('Choose your bank:')} htmlFor="bank-field" />
|
||||
<Autocomplete
|
||||
focused
|
||||
strict
|
||||
highlightFirst
|
||||
suggestions={bankOptions}
|
||||
onSelect={setSelectedAspsp}
|
||||
value={selectedAspsp}
|
||||
inputProps={{
|
||||
id: 'bank-field',
|
||||
placeholder: t('(please select)'),
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
))
|
||||
)}
|
||||
|
||||
<Warning>
|
||||
<Trans>
|
||||
By enabling bank sync, you will be granting Enable Banking (a third
|
||||
party service) read-only access to your entire account's transaction
|
||||
history. This service is not affiliated with Actual in any way. Make
|
||||
sure you've read and understand Enable Banking's{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://enablebanking.com/privacy-policy/"
|
||||
linkColor="purple"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>{' '}
|
||||
before proceeding.
|
||||
</Trans>
|
||||
</Warning>
|
||||
|
||||
<View style={{ flexDirection: 'row', gap: 10, alignItems: 'center' }}>
|
||||
<Button
|
||||
variant="primary"
|
||||
autoFocus
|
||||
style={{
|
||||
padding: '10px 0',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
flexGrow: 1,
|
||||
}}
|
||||
onPress={onJump}
|
||||
isDisabled={!selectedAspsp || !country}
|
||||
>
|
||||
<Trans>Link bank in browser</Trans> →
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
name="enablebanking-external-msg"
|
||||
onClose={onClose}
|
||||
containerProps={{ style: { width: '30vw' } }}
|
||||
>
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Link Your Bank')}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View>
|
||||
<Paragraph style={{ fontSize: 15 }}>
|
||||
<Trans>
|
||||
To link your bank account, you will be redirected to a new page
|
||||
where Enable Banking will ask to connect to your bank. Enable
|
||||
Banking will not be able to withdraw funds from your accounts.
|
||||
</Trans>
|
||||
</Paragraph>
|
||||
|
||||
{error && renderError(error, t)}
|
||||
|
||||
{waiting || isConfigurationLoading ? (
|
||||
<View style={{ alignItems: 'center', marginTop: 15 }}>
|
||||
<AnimatedLoading
|
||||
color={theme.pageTextDark}
|
||||
style={{ width: 20, height: 20 }}
|
||||
/>
|
||||
<View style={{ marginTop: 10, color: theme.pageText }}>
|
||||
{isConfigurationLoading
|
||||
? t('Checking Enable Banking configuration...')
|
||||
: waiting === 'browser'
|
||||
? t('Waiting on Enable Banking...')
|
||||
: waiting === 'accounts'
|
||||
? t('Loading accounts...')
|
||||
: null}
|
||||
</View>
|
||||
|
||||
{waiting === 'browser' && (
|
||||
<Link
|
||||
variant="text"
|
||||
onClick={onJump}
|
||||
style={{ marginTop: 10 }}
|
||||
>
|
||||
(
|
||||
<Trans>
|
||||
Account linking not opening in a new tab? Click here
|
||||
</Trans>
|
||||
)
|
||||
</Link>
|
||||
)}
|
||||
</View>
|
||||
) : isConfigured || isEnableBankingSetupComplete ? (
|
||||
renderLinkButton()
|
||||
) : (
|
||||
<>
|
||||
<Paragraph style={{ color: theme.errorText }}>
|
||||
<Trans>
|
||||
Enable Banking integration has not yet been configured.
|
||||
</Trans>
|
||||
</Paragraph>
|
||||
<Button variant="primary" onPress={onEnableBankingInit}>
|
||||
<Trans>Configure Enable Banking integration</Trans>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useState } from 'react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { ButtonWithLoading } from '@actual-app/components/button';
|
||||
import { SvgCheckCircle1 } from '@actual-app/components/icons/v2';
|
||||
import { InitialFocus } from '@actual-app/components/initial-focus';
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { send } from '@actual-app/core/platform/client/connection';
|
||||
import { getSecretsError } from '@actual-app/core/shared/errors';
|
||||
|
||||
import { Error as ErrorAlert } from '#components/alerts';
|
||||
import { Link } from '#components/common/Link';
|
||||
import {
|
||||
Modal,
|
||||
ModalButtons,
|
||||
ModalCloseButton,
|
||||
ModalHeader,
|
||||
} from '#components/common/Modal';
|
||||
import { FormField, FormLabel } from '#components/forms';
|
||||
import type { Modal as ModalType } from '#modals/modalsSlice';
|
||||
|
||||
type EnableBankingInitialiseProps = Extract<
|
||||
ModalType,
|
||||
{ name: 'enablebanking-init' }
|
||||
>['options'];
|
||||
|
||||
export function EnableBankingInitialiseModal({
|
||||
onSuccess,
|
||||
}: EnableBankingInitialiseProps) {
|
||||
const { t } = useTranslation();
|
||||
const [applicationId, setApplicationId] = useState('');
|
||||
const [secretKey, setSecretKey] = useState('');
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [keyFileName, setKeyFileName] = useState('');
|
||||
const [error, setError] = useState(
|
||||
t('It is required to provide both the Application ID and the secret key.'),
|
||||
);
|
||||
|
||||
async function onFileChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
setSecretKey(text);
|
||||
setKeyFileName(file.name);
|
||||
setIsValid(true);
|
||||
} catch {
|
||||
setSecretKey('');
|
||||
setKeyFileName('');
|
||||
setIsValid(false);
|
||||
setError(t('Failed to read the key file. Please try again.'));
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit(close: () => void) {
|
||||
if (!applicationId || !secretKey) {
|
||||
setIsValid(false);
|
||||
setError(
|
||||
t(
|
||||
'It is required to provide both the Application ID and the secret key.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const result = await send('enablebanking-configure', {
|
||||
applicationId,
|
||||
secretKey,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setIsValid(false);
|
||||
setError(getSecretsError(result.error, result.reason));
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.data?.error_code) {
|
||||
setIsValid(false);
|
||||
setError(
|
||||
result.data.error_type ||
|
||||
t(
|
||||
'Could not validate the credentials. Please check your Application ID and secret key.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsValid(true);
|
||||
onSuccess();
|
||||
close();
|
||||
} catch {
|
||||
setIsValid(false);
|
||||
setError(
|
||||
t(
|
||||
'Could not validate the credentials. Please check your Application ID and secret key.',
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
name="enablebanking-init"
|
||||
containerProps={{ style: { width: '30vw', minWidth: 420 } }}
|
||||
>
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Set up Enable Banking')}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View style={{ display: 'flex', gap: 10 }}>
|
||||
<Text>
|
||||
<Trans>
|
||||
In order to enable bank sync via Enable Banking (for EU banks)
|
||||
you will need to create application credentials. This can be
|
||||
done by creating an account at{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://enablebanking.com/cp/applications"
|
||||
linkColor="purple"
|
||||
>
|
||||
Enable Banking
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Text>
|
||||
<Trans>
|
||||
When setting up your application, use the following as the
|
||||
redirect URL:
|
||||
</Trans>{' '}
|
||||
<code>{window.location.origin}/enablebanking/auth_callback</code>
|
||||
</Text>
|
||||
|
||||
{window.location.protocol === 'http:' && (
|
||||
<ErrorAlert>
|
||||
<Trans>
|
||||
Enable Banking requires HTTPS for the redirect URL. Your
|
||||
current connection is not secure.
|
||||
</Trans>
|
||||
</ErrorAlert>
|
||||
)}
|
||||
|
||||
<FormField>
|
||||
<FormLabel
|
||||
title={t('Application ID:')}
|
||||
htmlFor="application-id-field"
|
||||
/>
|
||||
<InitialFocus>
|
||||
<Input
|
||||
id="application-id-field"
|
||||
type="text"
|
||||
value={applicationId}
|
||||
onChangeValue={value => {
|
||||
setApplicationId(value);
|
||||
setIsValid(true);
|
||||
}}
|
||||
/>
|
||||
</InitialFocus>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<FormLabel
|
||||
title={t('Secret Key (.pem file):')}
|
||||
htmlFor="secret-key-field"
|
||||
/>
|
||||
<input
|
||||
id="secret-key-field"
|
||||
type="file"
|
||||
accept=".pem,.key"
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{secretKey && (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<SvgCheckCircle1
|
||||
style={{ width: 14, height: 14, color: theme.noticeText }}
|
||||
/>
|
||||
<Text style={{ fontSize: 12, color: theme.pageTextSubdued }}>
|
||||
{keyFileName}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!isValid && <ErrorAlert>{error}</ErrorAlert>}
|
||||
</View>
|
||||
|
||||
<ModalButtons>
|
||||
<ButtonWithLoading
|
||||
variant="primary"
|
||||
isLoading={isLoading}
|
||||
onPress={() => {
|
||||
void onSubmit(() => state.close());
|
||||
}}
|
||||
>
|
||||
<Trans>Save and continue</Trans>
|
||||
</ButtonWithLoading>
|
||||
</ModalButtons>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { View } from '@actual-app/components/view';
|
||||
import { currentDay, subDays } from '@actual-app/core/shared/months';
|
||||
import type {
|
||||
AccountEntity,
|
||||
SyncServerEnableBankingAccount,
|
||||
SyncServerGoCardlessAccount,
|
||||
SyncServerPluggyAiAccount,
|
||||
SyncServerSimpleFinAccount,
|
||||
@@ -20,6 +21,7 @@ import type {
|
||||
import { format as formatDate, parseISO } from 'date-fns';
|
||||
|
||||
import {
|
||||
useLinkAccountEnableBankingMutation,
|
||||
useLinkAccountMutation,
|
||||
useLinkAccountPluggyAiMutation,
|
||||
useLinkAccountSimpleFinMutation,
|
||||
@@ -84,6 +86,11 @@ export type SelectLinkedAccountsModalProps =
|
||||
requisitionId?: undefined;
|
||||
externalAccounts: SyncServerPluggyAiAccount[];
|
||||
syncSource: 'pluggyai';
|
||||
}
|
||||
| {
|
||||
requisitionId?: undefined;
|
||||
externalAccounts: SyncServerEnableBankingAccount[];
|
||||
syncSource: 'enableBanking';
|
||||
};
|
||||
|
||||
export function SelectLinkedAccountsModal({
|
||||
@@ -116,6 +123,11 @@ export function SelectLinkedAccountsModal({
|
||||
requisitionId: requisitionId!,
|
||||
externalAccounts: toSort as SyncServerGoCardlessAccount[],
|
||||
};
|
||||
case 'enableBanking':
|
||||
return {
|
||||
syncSource: 'enableBanking',
|
||||
externalAccounts: toSort as SyncServerEnableBankingAccount[],
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unrecognized sync source: ${String(syncSource)}`);
|
||||
}
|
||||
@@ -148,6 +160,7 @@ export function SelectLinkedAccountsModal({
|
||||
const unlinkAccount = useUnlinkAccountMutation();
|
||||
const linkAccountSimpleFin = useLinkAccountSimpleFinMutation();
|
||||
const linkAccountPluggyAi = useLinkAccountPluggyAiMutation();
|
||||
const linkAccountEnableBanking = useLinkAccountEnableBankingMutation();
|
||||
|
||||
async function onNext() {
|
||||
const chosenLocalAccountIds = Object.values(chosenAccounts);
|
||||
@@ -213,6 +226,23 @@ export function SelectLinkedAccountsModal({
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
} else if (
|
||||
propsWithSortedExternalAccounts.syncSource === 'enableBanking'
|
||||
) {
|
||||
linkAccountEnableBanking.mutate({
|
||||
externalAccount:
|
||||
propsWithSortedExternalAccounts.externalAccounts[
|
||||
externalAccountIndex
|
||||
],
|
||||
upgradingId:
|
||||
chosenLocalAccountId !== addOnBudgetAccountOption.id &&
|
||||
chosenLocalAccountId !== addOffBudgetAccountOption.id
|
||||
? chosenLocalAccountId
|
||||
: undefined,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
} else {
|
||||
linkAccount.mutate({
|
||||
requisitionId: propsWithSortedExternalAccounts.requisitionId,
|
||||
@@ -468,7 +498,8 @@ export function SelectLinkedAccountsModal({
|
||||
type ExternalAccount =
|
||||
| SyncServerGoCardlessAccount
|
||||
| SyncServerSimpleFinAccount
|
||||
| SyncServerPluggyAiAccount;
|
||||
| SyncServerPluggyAiAccount
|
||||
| SyncServerEnableBankingAccount;
|
||||
|
||||
type StartingBalanceInfo = {
|
||||
date: string;
|
||||
@@ -706,7 +737,8 @@ function getInstitutionName(
|
||||
externalAccount:
|
||||
| SyncServerGoCardlessAccount
|
||||
| SyncServerSimpleFinAccount
|
||||
| SyncServerPluggyAiAccount,
|
||||
| SyncServerPluggyAiAccount
|
||||
| SyncServerEnableBankingAccount,
|
||||
) {
|
||||
if (typeof externalAccount?.institution === 'string') {
|
||||
return externalAccount?.institution ?? '';
|
||||
|
||||
@@ -228,6 +228,9 @@ export function ExperimentalFeatures() {
|
||||
>
|
||||
<Trans>Payee Locations</Trans>
|
||||
</FeatureToggle>
|
||||
<FeatureToggle flag="enableBanking">
|
||||
<Trans>Enable Banking sync (EU banks)</Trans>
|
||||
</FeatureToggle>
|
||||
{showServerPrefs && (
|
||||
<ServerFeatureToggle
|
||||
prefName="flags.plugins"
|
||||
|
||||
124
packages/desktop-client/src/enablebanking.ts
Normal file
124
packages/desktop-client/src/enablebanking.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { sendCatch } from '@actual-app/core/platform/client/connection';
|
||||
import type { SyncServerEnableBankingAccount } from '@actual-app/core/types/models';
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { pushModal } from './modals/modalsSlice';
|
||||
import type { AppDispatch } from './redux/store';
|
||||
|
||||
function _authorize(
|
||||
dispatch: AppDispatch,
|
||||
{
|
||||
onSuccess,
|
||||
onClose,
|
||||
}: {
|
||||
onSuccess: (data: {
|
||||
accounts: SyncServerEnableBankingAccount[];
|
||||
}) => Promise<void>;
|
||||
onClose?: () => void;
|
||||
},
|
||||
) {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'enablebanking-external-msg',
|
||||
options: {
|
||||
onMoveExternal: async ({ aspspId, country, maxConsentValidity }) => {
|
||||
const redirectUrl = `${window.location.origin}/enablebanking/auth_callback`;
|
||||
const resp = await sendCatch('enablebanking-start-auth', {
|
||||
aspspId,
|
||||
country,
|
||||
redirectUrl,
|
||||
maxConsentValidity,
|
||||
});
|
||||
|
||||
if (resp.error) {
|
||||
return {
|
||||
error: 'unknown' as const,
|
||||
message: resp.error.message,
|
||||
};
|
||||
}
|
||||
|
||||
const authData = resp.data;
|
||||
|
||||
if (authData?.error) {
|
||||
return {
|
||||
error: 'unknown' as const,
|
||||
message: authData.error,
|
||||
};
|
||||
}
|
||||
|
||||
const authUrl = authData?.data?.url ?? authData?.url;
|
||||
const state = authData?.data?.state ?? authData?.state;
|
||||
|
||||
if (!authUrl || !state) {
|
||||
return {
|
||||
error: 'unknown' as const,
|
||||
message: t('Missing auth URL or state'),
|
||||
};
|
||||
}
|
||||
|
||||
localStorage.setItem('enablebanking_auth_state', state);
|
||||
window.open(
|
||||
authUrl,
|
||||
'enablebanking-auth',
|
||||
'width=600,height=700,popup=yes',
|
||||
);
|
||||
|
||||
const pollResp = await sendCatch('enablebanking-poll-auth', {
|
||||
state,
|
||||
});
|
||||
|
||||
if (pollResp.error) {
|
||||
if (pollResp.error.message === 'timeout') {
|
||||
return { error: 'timeout' as const };
|
||||
}
|
||||
|
||||
return {
|
||||
error: 'unknown' as const,
|
||||
message: pollResp.error.message,
|
||||
};
|
||||
}
|
||||
|
||||
const pollData = pollResp.data;
|
||||
|
||||
// The poll response body itself may carry an error (e.g. when
|
||||
// the bank callback failed before the poll started).
|
||||
const pollError = pollData?.data?.error ?? pollData?.error;
|
||||
if (pollError) {
|
||||
return {
|
||||
error: 'unknown' as const,
|
||||
message:
|
||||
typeof pollError === 'string' ? pollError : String(pollError),
|
||||
};
|
||||
}
|
||||
|
||||
const accounts: SyncServerEnableBankingAccount[] =
|
||||
pollData?.data?.accounts ?? pollData?.accounts ?? [];
|
||||
|
||||
return { data: { accounts } };
|
||||
},
|
||||
onClose,
|
||||
onSuccess,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function authorizeBank(dispatch: AppDispatch) {
|
||||
_authorize(dispatch, {
|
||||
onSuccess: async data => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'select-linked-accounts',
|
||||
options: {
|
||||
externalAccounts: data.accounts,
|
||||
syncSource: 'enableBanking',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
38
packages/desktop-client/src/hooks/useEnableBankingStatus.ts
Normal file
38
packages/desktop-client/src/hooks/useEnableBankingStatus.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { send } from '@actual-app/core/platform/client/connection';
|
||||
|
||||
import { useSyncServerStatus } from './useSyncServerStatus';
|
||||
|
||||
export function useEnableBankingStatus(enabled = true) {
|
||||
const [configuredEnableBanking, setConfiguredEnableBanking] = useState<
|
||||
boolean | null
|
||||
>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const status = useSyncServerStatus();
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
async function fetch() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const results = await send('enablebanking-status');
|
||||
setConfiguredEnableBanking(results.configured || false);
|
||||
} catch {
|
||||
setConfiguredEnableBanking(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'online') {
|
||||
void fetch();
|
||||
}
|
||||
}, [status, enabled]);
|
||||
|
||||
return {
|
||||
configuredEnableBanking,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
|
||||
customThemes: false,
|
||||
budgetAnalysisReport: false,
|
||||
payeeLocations: false,
|
||||
enableBanking: false,
|
||||
sankeyReport: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
NoteEntity,
|
||||
RuleEntity,
|
||||
ScheduleEntity,
|
||||
SyncServerEnableBankingAccount,
|
||||
TransactionEntity,
|
||||
UserAccessEntity,
|
||||
UserEntity,
|
||||
@@ -129,6 +130,30 @@ export type Modal =
|
||||
onSuccess: () => void;
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: 'enablebanking-init';
|
||||
options: {
|
||||
onSuccess: () => void;
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: 'enablebanking-external-msg';
|
||||
options: {
|
||||
onMoveExternal: (arg: {
|
||||
aspspId: string;
|
||||
country: string;
|
||||
maxConsentValidity?: number;
|
||||
}) => Promise<
|
||||
| { error: 'timeout' }
|
||||
| { error: 'unknown'; message?: string }
|
||||
| { data: { accounts: SyncServerEnableBankingAccount[] } }
|
||||
>;
|
||||
onClose?: (() => void) | undefined;
|
||||
onSuccess: (data: {
|
||||
accounts: SyncServerEnableBankingAccount[];
|
||||
}) => Promise<void>;
|
||||
};
|
||||
}
|
||||
| {
|
||||
name: 'gocardless-external-msg';
|
||||
options: {
|
||||
|
||||
@@ -211,6 +211,7 @@ export default defineConfig(async ({ mode }) => {
|
||||
/^\/plugins\/.*$/,
|
||||
/^\/kcab\/.*$/,
|
||||
/^\/plugin-data\/.*$/,
|
||||
/^\/enablebanking\/.*$/,
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
CategoryEntity,
|
||||
GoCardlessToken,
|
||||
ImportTransactionEntity,
|
||||
SyncServerEnableBankingAccount,
|
||||
SyncServerGoCardlessAccount,
|
||||
SyncServerPluggyAiAccount,
|
||||
SyncServerSimpleFinAccount,
|
||||
@@ -55,6 +56,7 @@ export type AccountHandlers = {
|
||||
'gocardless-accounts-link': typeof linkGoCardlessAccount;
|
||||
'simplefin-accounts-link': typeof linkSimpleFinAccount;
|
||||
'pluggyai-accounts-link': typeof linkPluggyAiAccount;
|
||||
'enablebanking-accounts-link': typeof linkEnableBankingAccount;
|
||||
'account-create': typeof createAccount;
|
||||
'account-close': typeof closeAccount;
|
||||
'account-reopen': typeof reopenAccount;
|
||||
@@ -66,6 +68,13 @@ export type AccountHandlers = {
|
||||
'gocardless-status': typeof goCardlessStatus;
|
||||
'simplefin-status': typeof simpleFinStatus;
|
||||
'pluggyai-status': typeof pluggyAiStatus;
|
||||
'enablebanking-status': typeof enableBankingStatus;
|
||||
'enablebanking-aspsps': typeof enableBankingAspsps;
|
||||
'enablebanking-start-auth': typeof enableBankingStartAuth;
|
||||
'enablebanking-complete-auth': typeof enableBankingCompleteAuth;
|
||||
'enablebanking-poll-auth': typeof enableBankingPollAuth;
|
||||
'enablebanking-poll-auth-stop': typeof stopEnableBankingPollAuth;
|
||||
'enablebanking-configure': typeof enableBankingConfigure;
|
||||
'simplefin-accounts': typeof simpleFinAccounts;
|
||||
'pluggyai-accounts': typeof pluggyAiAccounts;
|
||||
'gocardless-get-banks': typeof getGoCardlessBanks;
|
||||
@@ -364,6 +373,82 @@ async function linkPluggyAiAccount({
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
async function linkEnableBankingAccount({
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
offBudget = false,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
}: LinkAccountBaseParams & {
|
||||
externalAccount: SyncServerEnableBankingAccount;
|
||||
}) {
|
||||
let id;
|
||||
|
||||
const institution = {
|
||||
name: externalAccount.institution ?? t('Unknown'),
|
||||
};
|
||||
|
||||
// Enable Banking uses a session-per-account model, so we use the
|
||||
// account-level identifier (account_id) rather than institution-level
|
||||
// IDs. This creates one bank entry per Enable Banking account, unlike
|
||||
// GoCardless (requisitionId) or SimpleFin/PluggyAi (orgDomain/orgId).
|
||||
const bank = await link.findOrCreateBank(
|
||||
institution,
|
||||
externalAccount.account_id,
|
||||
);
|
||||
|
||||
if (upgradingId) {
|
||||
const accRow = await db.first<db.DbAccount>(
|
||||
'SELECT * FROM accounts WHERE id = ?',
|
||||
[upgradingId],
|
||||
);
|
||||
|
||||
if (!accRow) {
|
||||
throw new Error(`Account with ID ${upgradingId} not found.`);
|
||||
}
|
||||
|
||||
id = accRow.id;
|
||||
await db.update('accounts', {
|
||||
id,
|
||||
account_id: externalAccount.account_id,
|
||||
bank: bank.id,
|
||||
account_sync_source: 'enableBanking',
|
||||
});
|
||||
} 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: 'enableBanking',
|
||||
});
|
||||
await db.insertPayee({
|
||||
name: '',
|
||||
transfer_acct: id,
|
||||
});
|
||||
}
|
||||
|
||||
await bankSync.syncAccount(
|
||||
undefined,
|
||||
undefined,
|
||||
id,
|
||||
externalAccount.account_id,
|
||||
bank.bank_id,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
);
|
||||
|
||||
connection.send('sync-event', {
|
||||
type: 'success',
|
||||
tables: ['transactions'],
|
||||
});
|
||||
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
async function createAccount({
|
||||
name,
|
||||
balance = 0,
|
||||
@@ -784,6 +869,182 @@ async function pluggyAiAccounts() {
|
||||
}
|
||||
}
|
||||
|
||||
async function enableBankingStatus() {
|
||||
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.ENABLEBANKING_SERVER + '/status',
|
||||
{},
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function enableBankingAspsps(country: string) {
|
||||
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.ENABLEBANKING_SERVER + '/aspsps',
|
||||
{ country },
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function enableBankingStartAuth({
|
||||
aspspId,
|
||||
country,
|
||||
redirectUrl,
|
||||
maxConsentValidity,
|
||||
}: {
|
||||
aspspId: string;
|
||||
country: string;
|
||||
redirectUrl: string;
|
||||
maxConsentValidity?: number;
|
||||
}) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
|
||||
if (!userToken) {
|
||||
return { error: 'unauthorized' };
|
||||
}
|
||||
|
||||
if (
|
||||
maxConsentValidity !== undefined &&
|
||||
(!Number.isFinite(maxConsentValidity) ||
|
||||
!Number.isInteger(maxConsentValidity) ||
|
||||
maxConsentValidity <= 0 ||
|
||||
maxConsentValidity > 315_360_000)
|
||||
) {
|
||||
return { error: 'invalid_max_consent_validity' };
|
||||
}
|
||||
|
||||
const serverConfig = getServer();
|
||||
if (!serverConfig) {
|
||||
throw new Error('Failed to get server config.');
|
||||
}
|
||||
|
||||
return post(
|
||||
serverConfig.ENABLEBANKING_SERVER + '/start-auth',
|
||||
{ aspsp: { name: aspspId, country }, redirectUrl, maxConsentValidity },
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function enableBankingCompleteAuth({
|
||||
code,
|
||||
state,
|
||||
}: {
|
||||
code: string;
|
||||
state: string;
|
||||
}) {
|
||||
if (!state) {
|
||||
return { error: 'missing-state' };
|
||||
}
|
||||
|
||||
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.ENABLEBANKING_SERVER + '/complete-auth',
|
||||
{ code, state },
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const enableBankingPollControllers = new Map<string, AbortController>();
|
||||
|
||||
async function enableBankingPollAuth({ state }: { state: string }) {
|
||||
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.');
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
enableBankingPollControllers.set(state, controller);
|
||||
|
||||
try {
|
||||
return await post(
|
||||
serverConfig.ENABLEBANKING_SERVER + '/poll-auth',
|
||||
{ state },
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
310000, // slightly longer than server's 5-minute poll timeout
|
||||
controller.signal,
|
||||
);
|
||||
} finally {
|
||||
if (enableBankingPollControllers.get(state) === controller) {
|
||||
enableBankingPollControllers.delete(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stopEnableBankingPollAuth() {
|
||||
for (const [state, controller] of enableBankingPollControllers) {
|
||||
controller.abort();
|
||||
enableBankingPollControllers.delete(state);
|
||||
}
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
async function enableBankingConfigure(config: {
|
||||
applicationId: string;
|
||||
secretKey: string;
|
||||
}) {
|
||||
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.ENABLEBANKING_SERVER + '/configure', config, {
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
});
|
||||
}
|
||||
|
||||
async function getGoCardlessBanks(country: string) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
|
||||
@@ -1283,6 +1544,7 @@ 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('enablebanking-accounts-link', linkEnableBankingAccount);
|
||||
app.method('account-create', mutator(undoable(createAccount)));
|
||||
app.method('account-close', mutator(closeAccount));
|
||||
app.method('account-reopen', mutator(undoable(reopenAccount)));
|
||||
@@ -1294,6 +1556,13 @@ 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('enablebanking-status', enableBankingStatus);
|
||||
app.method('enablebanking-aspsps', enableBankingAspsps);
|
||||
app.method('enablebanking-start-auth', enableBankingStartAuth);
|
||||
app.method('enablebanking-complete-auth', enableBankingCompleteAuth);
|
||||
app.method('enablebanking-poll-auth', enableBankingPollAuth);
|
||||
app.method('enablebanking-poll-auth-stop', stopEnableBankingPollAuth);
|
||||
app.method('enablebanking-configure', enableBankingConfigure);
|
||||
app.method('simplefin-accounts', simpleFinAccounts);
|
||||
app.method('pluggyai-accounts', pluggyAiAccounts);
|
||||
app.method('gocardless-get-banks', getGoCardlessBanks);
|
||||
|
||||
@@ -312,6 +312,44 @@ async function downloadPluggyAiTransactions(
|
||||
return retVal;
|
||||
}
|
||||
|
||||
async function downloadEnableBankingTransactions(
|
||||
acctId: string,
|
||||
since: string,
|
||||
) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
if (!userToken) return;
|
||||
|
||||
logger.log('Pulling transactions from Enable Banking');
|
||||
|
||||
const res = await post(
|
||||
getServer().ENABLEBANKING_SERVER + '/transactions',
|
||||
{
|
||||
accountId: acctId,
|
||||
startDate: since,
|
||||
},
|
||||
{
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
},
|
||||
60000,
|
||||
);
|
||||
|
||||
if (res.error_code) {
|
||||
throw BankSyncError(res.error_type, res.error_code);
|
||||
}
|
||||
|
||||
const {
|
||||
transactions: { all },
|
||||
balances,
|
||||
startingBalance,
|
||||
} = res;
|
||||
|
||||
return {
|
||||
transactions: all,
|
||||
accountBalance: balances,
|
||||
startingBalance,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolvePayee(trans, payeeName, payeesToCreate) {
|
||||
if (trans.payee == null && payeeName) {
|
||||
// First check our registry of new payees (to avoid a db access)
|
||||
@@ -979,6 +1017,11 @@ async function processBankSyncDownload(
|
||||
currentBalance,
|
||||
);
|
||||
balanceToUse = Math.round(previousBalance);
|
||||
} else if (acctRow.account_sync_source === 'enableBanking') {
|
||||
const previousBalance = transactions.reduce((total, trans) => {
|
||||
return total - amountToInteger(trans.transactionAmount.amount);
|
||||
}, currentBalance);
|
||||
balanceToUse = previousBalance;
|
||||
}
|
||||
|
||||
const oldestTransaction = transactions[transactions.length - 1];
|
||||
@@ -1076,6 +1119,8 @@ export async function syncAccount(
|
||||
syncStartDate,
|
||||
newAccount,
|
||||
);
|
||||
} else if (acctRow.account_sync_source === 'enableBanking') {
|
||||
download = await downloadEnableBankingTransactions(acctId, syncStartDate);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unrecognized bank-sync provider: ${acctRow.account_sync_source}`,
|
||||
|
||||
@@ -38,14 +38,27 @@ export async function post(
|
||||
data: unknown,
|
||||
headers = {},
|
||||
timeout: number | null = null,
|
||||
externalSignal?: AbortSignal | null,
|
||||
) {
|
||||
let text: string;
|
||||
let res: Response;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId =
|
||||
timeout != null ? setTimeout(() => controller.abort(), timeout) : undefined;
|
||||
|
||||
// If an external signal is provided, abort our controller when it fires
|
||||
const onExternalAbort = () => controller.abort();
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
controller.abort();
|
||||
} else {
|
||||
externalSignal.addEventListener('abort', onExternalAbort);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
const signal = timeout ? controller.signal : null;
|
||||
const signal = timeout != null || externalSignal ? controller.signal : null;
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
@@ -55,10 +68,19 @@ export async function post(
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
text = await res.text();
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.name === 'AbortError' &&
|
||||
externalSignal?.aborted
|
||||
) {
|
||||
throw new PostError('aborted');
|
||||
}
|
||||
throw new PostError('network-failure');
|
||||
} finally {
|
||||
if (timeoutId != null) clearTimeout(timeoutId);
|
||||
externalSignal?.removeEventListener('abort', onExternalAbort);
|
||||
}
|
||||
|
||||
throwIfNot200(res, text);
|
||||
|
||||
@@ -8,6 +8,7 @@ type ServerConfig = {
|
||||
GOCARDLESS_SERVER: string;
|
||||
SIMPLEFIN_SERVER: string;
|
||||
PLUGGYAI_SERVER: string;
|
||||
ENABLEBANKING_SERVER: string;
|
||||
};
|
||||
|
||||
let config: ServerConfig | null = null;
|
||||
@@ -45,6 +46,7 @@ export function getServer(url?: string): ServerConfig | null {
|
||||
GOCARDLESS_SERVER: joinURL(url, '/gocardless'),
|
||||
SIMPLEFIN_SERVER: joinURL(url, '/simplefin'),
|
||||
PLUGGYAI_SERVER: joinURL(url, '/pluggyai'),
|
||||
ENABLEBANKING_SERVER: joinURL(url, '/enablebanking'),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
|
||||
@@ -21,4 +21,8 @@ export type AccountEntity = {
|
||||
last_sync: string | null;
|
||||
};
|
||||
|
||||
export type AccountSyncSource = 'simpleFin' | 'goCardless' | 'pluggyai';
|
||||
export type AccountSyncSource =
|
||||
| 'simpleFin'
|
||||
| 'goCardless'
|
||||
| 'pluggyai'
|
||||
| 'enableBanking';
|
||||
|
||||
@@ -20,4 +20,8 @@ export type BankSyncResponse = {
|
||||
error_code: string;
|
||||
};
|
||||
|
||||
export type BankSyncProviders = 'goCardless' | 'simpleFin' | 'pluggyai';
|
||||
export type BankSyncProviders =
|
||||
| 'goCardless'
|
||||
| 'simpleFin'
|
||||
| 'pluggyai'
|
||||
| 'enableBanking';
|
||||
|
||||
66
packages/loot-core/src/types/models/enablebanking.ts
Normal file
66
packages/loot-core/src/types/models/enablebanking.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// Enable Banking API response types (from https://enablebanking.com/docs/api/reference/)
|
||||
|
||||
export type EnableBankingAccountId = {
|
||||
iban?: string;
|
||||
};
|
||||
|
||||
export type EnableBankingAccountServicer = {
|
||||
bic_fi?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type EnableBankingAspsp = {
|
||||
name: string;
|
||||
country: string;
|
||||
logo?: string;
|
||||
psu_types?: string[];
|
||||
beta?: boolean;
|
||||
maximum_consent_validity?: number;
|
||||
};
|
||||
|
||||
export type EnableBankingSessionAccount = {
|
||||
account_id: EnableBankingAccountId;
|
||||
account_servicer?: EnableBankingAccountServicer;
|
||||
name?: string;
|
||||
currency?: string;
|
||||
uid: string;
|
||||
identification_hash?: string;
|
||||
};
|
||||
|
||||
export type EnableBankingSession = {
|
||||
session_id: string;
|
||||
accounts: EnableBankingSessionAccount[];
|
||||
aspsp: { name: string; country: string };
|
||||
access?: { valid_until: string };
|
||||
};
|
||||
|
||||
export type EnableBankingRawTransaction = {
|
||||
entry_reference?: string;
|
||||
transaction_id?: string;
|
||||
transaction_amount: { currency: string; amount: string };
|
||||
creditor?: { name?: string };
|
||||
debtor?: { name?: string };
|
||||
creditor_account?: { iban?: string };
|
||||
debtor_account?: { iban?: string };
|
||||
credit_debit_indicator?: 'CRDT' | 'DBIT';
|
||||
status: 'BOOK' | 'PDNG';
|
||||
booking_date?: string;
|
||||
value_date?: string;
|
||||
transaction_date?: string;
|
||||
remittance_information?: string[];
|
||||
balance_after_transaction?: { currency: string; amount: string };
|
||||
};
|
||||
|
||||
export type EnableBankingRawBalance = {
|
||||
balance_amount: { currency: string; amount: string };
|
||||
balance_type?: string;
|
||||
reference_date?: string;
|
||||
};
|
||||
|
||||
// Normalized type for client-side account selection (matches SimpleFIN/PluggyAI pattern)
|
||||
export type SyncServerEnableBankingAccount = {
|
||||
account_id: string;
|
||||
name: string;
|
||||
institution: string;
|
||||
balance: number;
|
||||
};
|
||||
@@ -4,6 +4,7 @@ export type * from './bank-sync';
|
||||
export type * from './category';
|
||||
export type * from './category-group';
|
||||
export type * from './dashboard';
|
||||
export type * from './enablebanking';
|
||||
export type * from './gocardless';
|
||||
export type * from './import-transaction';
|
||||
export type * from './nearby-payee';
|
||||
|
||||
@@ -9,6 +9,7 @@ export type FeatureFlag =
|
||||
| 'customThemes'
|
||||
| 'budgetAnalysisReport'
|
||||
| 'payeeLocations'
|
||||
| 'enableBanking'
|
||||
| 'sankeyReport';
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
"#load-config": "./src/load-config.js",
|
||||
"#migrations": "./src/migrations.ts",
|
||||
"#accounts/*": "./src/accounts/*.js",
|
||||
"#app-enablebanking/services/*": "./src/app-enablebanking/services/*.ts",
|
||||
"#app-enablebanking/utils/*": "./src/app-enablebanking/utils/*.ts",
|
||||
"#app-enablebanking/*": "./src/app-enablebanking/*.ts",
|
||||
"#app-gocardless/banks/bank.interface": "./src/app-gocardless/banks/bank.interface.ts",
|
||||
"#app-gocardless/banks/*": "./src/app-gocardless/banks/*.js",
|
||||
"#app-gocardless/errors": "./src/app-gocardless/errors.ts",
|
||||
@@ -41,6 +44,9 @@
|
||||
"#load-config": "./build/src/load-config.js",
|
||||
"#migrations": "./build/src/migrations.js",
|
||||
"#accounts/*": "./build/src/accounts/*.js",
|
||||
"#app-enablebanking/services/*": "./build/src/app-enablebanking/services/*.js",
|
||||
"#app-enablebanking/utils/*": "./build/src/app-enablebanking/utils/*.js",
|
||||
"#app-enablebanking/*": "./build/src/app-enablebanking/*.js",
|
||||
"#app-gocardless/banks/bank.interface": "./build/src/app-gocardless/banks/bank.interface.js",
|
||||
"#app-gocardless/banks/*": "./build/src/app-gocardless/banks/*.js",
|
||||
"#app-gocardless/errors": "./build/src/app-gocardless/errors.js",
|
||||
@@ -105,6 +111,7 @@
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/express-actuator": "^1.8.3",
|
||||
"@types/jws": "^3.2.11",
|
||||
"@types/node": "^22.19.15",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
|
||||
584
packages/sync-server/src/app-enablebanking/app-enablebanking.ts
Normal file
584
packages/sync-server/src/app-enablebanking/app-enablebanking.ts
Normal file
@@ -0,0 +1,584 @@
|
||||
import createDebug from 'debug';
|
||||
import type { Request, Response } from 'express';
|
||||
import express from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { handleError } from '#app-gocardless/util/handle-error';
|
||||
import { SecretName, secretsService } from '#services/secrets-service';
|
||||
import {
|
||||
requestLoggerMiddleware,
|
||||
validateSessionMiddleware,
|
||||
} from '#util/middlewares';
|
||||
|
||||
import type { PsuHeaders } from './services/enablebanking-service';
|
||||
import {
|
||||
enableBankingService,
|
||||
normalizeAccount,
|
||||
normalizeBalance,
|
||||
normalizeTransaction,
|
||||
} from './services/enablebanking-service';
|
||||
import { EnableBankingError } from './utils/errors';
|
||||
|
||||
const debug = createDebug('actual:enable-banking:app');
|
||||
|
||||
const app = express();
|
||||
export { app as handlers };
|
||||
app.use(requestLoggerMiddleware);
|
||||
app.use(express.json());
|
||||
|
||||
// --- Shared helpers ---
|
||||
|
||||
function extractPsuHeaders(req: Request): PsuHeaders {
|
||||
const ip =
|
||||
(typeof req.headers['x-forwarded-for'] === 'string'
|
||||
? req.headers['x-forwarded-for'].split(',')[0].trim()
|
||||
: undefined) || req.ip;
|
||||
const ua =
|
||||
typeof req.headers['user-agent'] === 'string'
|
||||
? req.headers['user-agent']
|
||||
: undefined;
|
||||
|
||||
const headers: PsuHeaders = {};
|
||||
if (ip) headers['Psu-Ip-Address'] = ip;
|
||||
if (ua) headers['Psu-User-Agent'] = ua;
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function buildSessionResult(
|
||||
session: {
|
||||
session_id: string;
|
||||
accounts: { uid: string; [key: string]: unknown }[];
|
||||
aspsp?: { name?: string; country?: string };
|
||||
},
|
||||
psuHeaders?: PsuHeaders,
|
||||
) {
|
||||
const accountsWithBalances = await Promise.all(
|
||||
session.accounts.map(async account => {
|
||||
const normalized = normalizeAccount(
|
||||
account as Parameters<typeof normalizeAccount>[0],
|
||||
session.aspsp,
|
||||
);
|
||||
|
||||
let balances: ReturnType<typeof normalizeBalance>[] = [];
|
||||
try {
|
||||
const balanceResult = await enableBankingService.getBalances(
|
||||
account.uid,
|
||||
psuHeaders,
|
||||
);
|
||||
balances = balanceResult.balances.map(normalizeBalance);
|
||||
} catch (err) {
|
||||
debug('Failed to fetch balances for account %s: %s', account.uid, err);
|
||||
}
|
||||
|
||||
const preferredBalance =
|
||||
balances.find(b => b.balanceType === 'CLAV') ?? balances[0];
|
||||
|
||||
return {
|
||||
...normalized,
|
||||
balance: preferredBalance ? preferredBalance.balanceAmount.amount : 0,
|
||||
balances,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
session_id: session.session_id,
|
||||
accounts: accountsWithBalances,
|
||||
aspsp: session.aspsp,
|
||||
};
|
||||
}
|
||||
|
||||
// Auth callback from bank redirect — must be before validateSessionMiddleware
|
||||
// since the bank redirects here directly (no auth token available)
|
||||
app.get('/auth_callback', async (req: Request, res: Response) => {
|
||||
const code = typeof req.query.code === 'string' ? req.query.code : undefined;
|
||||
const state =
|
||||
typeof req.query.state === 'string' ? req.query.state : undefined;
|
||||
|
||||
if (!code) {
|
||||
res
|
||||
.status(400)
|
||||
.send(
|
||||
'<html><body><p>Authorization failed: missing code.</p></body></html>',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
res
|
||||
.status(400)
|
||||
.send(
|
||||
'<html><body><p>Authorization failed: missing state parameter.</p></body></html>',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await enableBankingService.createSession(code);
|
||||
debug(
|
||||
'Callback session created: %s with %d accounts',
|
||||
session.session_id,
|
||||
session.accounts.length,
|
||||
);
|
||||
|
||||
const result = await buildSessionResult(session, extractPsuHeaders(req));
|
||||
|
||||
// Always cache the result so retries within TTL can read it
|
||||
completedAuths.set(state, result);
|
||||
setTimeout(() => completedAuths.delete(state), COMPLETED_AUTH_TTL_MS);
|
||||
|
||||
if (pendingAuths.has(state)) {
|
||||
const pending = pendingAuths.get(state)!;
|
||||
pending.resolve(result);
|
||||
cleanupPendingAuth(state);
|
||||
}
|
||||
|
||||
res.send(
|
||||
'<html><body><p>Authorization successful. This window will close.</p>' +
|
||||
'<script>setTimeout(function(){window.close()},1000)</script></body></html>',
|
||||
);
|
||||
} catch (error) {
|
||||
const errorResult = {
|
||||
error: error instanceof Error ? error.message : 'unknown error',
|
||||
};
|
||||
|
||||
completedAuths.set(state, errorResult);
|
||||
setTimeout(() => completedAuths.delete(state), COMPLETED_AUTH_TTL_MS);
|
||||
|
||||
if (pendingAuths.has(state)) {
|
||||
const pending = pendingAuths.get(state)!;
|
||||
pending.reject(error);
|
||||
cleanupPendingAuth(state);
|
||||
}
|
||||
|
||||
debug('Callback auth error: %s', error);
|
||||
res
|
||||
.status(500)
|
||||
.send(
|
||||
'<html><body><p>Authorization failed. You can close this window and try again.</p></body></html>',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
app.use(validateSessionMiddleware);
|
||||
|
||||
// --- Poll/complete-auth coordination ---
|
||||
|
||||
type PendingAuth = {
|
||||
id: string;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (reason: unknown) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
// NOTE: These in-memory maps make the auth handoff process-local.
|
||||
// Multi-instance deployments require sticky routing so the same instance
|
||||
// handles both the callback and client poll for a given state.
|
||||
const pendingAuths = new Map<string, PendingAuth>();
|
||||
const completedAuths = new Map<string, unknown>();
|
||||
let nextWaiterId = 0;
|
||||
|
||||
const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const COMPLETED_AUTH_TTL_MS = 30 * 1000; // 30 seconds
|
||||
|
||||
function cleanupPendingAuth(state: string, waiterId?: string) {
|
||||
const entry = pendingAuths.get(state);
|
||||
if (entry && (waiterId == null || entry.id === waiterId)) {
|
||||
clearTimeout(entry.timer);
|
||||
pendingAuths.delete(state);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
app.post(
|
||||
'/status',
|
||||
handleError(async (req: Request, res: Response) => {
|
||||
const configured = enableBankingService.isConfigured();
|
||||
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
configured,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/configure',
|
||||
handleError(async (req: Request, res: Response) => {
|
||||
const { applicationId, secretKey } = req.body || {};
|
||||
|
||||
if (!applicationId || !secretKey) {
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
error_code: 'INVALID_INPUT',
|
||||
error_type: 'Missing applicationId or secretKey',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate credentials before persisting to avoid exposing
|
||||
// transient bad creds to concurrent requests
|
||||
try {
|
||||
const appInfo = await enableBankingService.validateCredentials(
|
||||
applicationId,
|
||||
secretKey,
|
||||
);
|
||||
debug('Enable Banking application validated: %o', appInfo);
|
||||
} catch (error) {
|
||||
debug('Enable Banking configuration validation failed: %s', error);
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
error_code: 'CONFIGURATION_FAILED',
|
||||
error_type: error instanceof Error ? error.message : 'unknown error',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Only persist after successful validation
|
||||
secretsService.set(SecretName.enablebanking_applicationId, applicationId);
|
||||
secretsService.set(SecretName.enablebanking_secretKey, secretKey);
|
||||
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
configured: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/aspsps',
|
||||
handleError(async (req: Request, res: Response) => {
|
||||
const { country } = req.body || {};
|
||||
|
||||
try {
|
||||
const aspsps = await enableBankingService.getAspsps(country);
|
||||
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: aspsps,
|
||||
});
|
||||
} catch (error) {
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
error: error instanceof Error ? error.message : 'unknown error',
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/start-auth',
|
||||
handleError(async (req: Request, res: Response) => {
|
||||
const { aspsp, redirectUrl, maxConsentValidity } = req.body || {};
|
||||
|
||||
if (!aspsp || !redirectUrl) {
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
error_code: 'INVALID_INPUT',
|
||||
error_type: 'Missing aspsp or redirectUrl',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const state = uuidv4();
|
||||
|
||||
try {
|
||||
const authResponse = await enableBankingService.startAuth(
|
||||
aspsp,
|
||||
redirectUrl,
|
||||
state,
|
||||
typeof maxConsentValidity === 'number' ? maxConsentValidity : undefined,
|
||||
);
|
||||
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
url: authResponse.url,
|
||||
state,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
error: error instanceof Error ? error.message : 'unknown error',
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/complete-auth',
|
||||
handleError(async (req: Request, res: Response) => {
|
||||
const { code, state } = req.body || {};
|
||||
|
||||
if (!code) {
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
error_code: 'INVALID_INPUT',
|
||||
error_type: 'Missing code',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await enableBankingService.createSession(code);
|
||||
debug(
|
||||
'Session created: %s with %d accounts',
|
||||
session.session_id,
|
||||
session.accounts.length,
|
||||
);
|
||||
|
||||
const result = await buildSessionResult(session, extractPsuHeaders(req));
|
||||
|
||||
// Always cache so retries within TTL can read the result
|
||||
if (state) {
|
||||
completedAuths.set(state, result);
|
||||
setTimeout(() => completedAuths.delete(state), COMPLETED_AUTH_TTL_MS);
|
||||
|
||||
if (pendingAuths.has(state)) {
|
||||
const pending = pendingAuths.get(state)!;
|
||||
pending.resolve(result);
|
||||
cleanupPendingAuth(state);
|
||||
}
|
||||
}
|
||||
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorResult = {
|
||||
error: error instanceof Error ? error.message : 'unknown error',
|
||||
};
|
||||
|
||||
if (state) {
|
||||
completedAuths.set(state, errorResult);
|
||||
setTimeout(() => completedAuths.delete(state), COMPLETED_AUTH_TTL_MS);
|
||||
|
||||
if (pendingAuths.has(state)) {
|
||||
const pending = pendingAuths.get(state)!;
|
||||
pending.reject(error);
|
||||
cleanupPendingAuth(state);
|
||||
}
|
||||
}
|
||||
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: errorResult,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/poll-auth',
|
||||
handleError(async (req: Request, res: Response) => {
|
||||
const { state } = req.body || {};
|
||||
|
||||
if (!state) {
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
error_code: 'INVALID_INPUT',
|
||||
error_type: 'Missing state',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const waiterId = String(++nextWaiterId);
|
||||
|
||||
try {
|
||||
// If complete-auth already fired before poll-auth, return immediately
|
||||
if (completedAuths.has(state)) {
|
||||
const result = completedAuths.get(state);
|
||||
completedAuths.delete(state);
|
||||
res.send({ status: 'ok', data: result });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
// Clean up any existing waiter for this state
|
||||
if (pendingAuths.has(state)) {
|
||||
const existing = pendingAuths.get(state)!;
|
||||
clearTimeout(existing.timer);
|
||||
existing.reject(new Error('Poll superseded'));
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const safeResolve = (value: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
const safeReject = (reason: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(reason);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cleanupPendingAuth(state, waiterId);
|
||||
safeReject(new Error('Polling timed out'));
|
||||
}, POLL_TIMEOUT_MS);
|
||||
|
||||
pendingAuths.set(state, {
|
||||
id: waiterId,
|
||||
resolve: safeResolve,
|
||||
reject: safeReject,
|
||||
timer,
|
||||
});
|
||||
|
||||
// Clean up if client disconnects before resolution
|
||||
res.on('close', () => {
|
||||
if (!res.writableFinished && !settled) {
|
||||
cleanupPendingAuth(state, waiterId);
|
||||
safeReject(new Error('Client disconnected'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
cleanupPendingAuth(state, waiterId);
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
error: error instanceof Error ? error.message : 'unknown error',
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
app.post(
|
||||
'/transactions',
|
||||
handleError(async (req: Request, res: Response) => {
|
||||
const { accountId, startDate } = req.body || {};
|
||||
|
||||
if (!accountId || !startDate) {
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
error_code: 'INVALID_INPUT',
|
||||
error_type: 'Missing accountId or startDate',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const psuHeaders = extractPsuHeaders(req);
|
||||
|
||||
try {
|
||||
const dateTo = new Date().toISOString().split('T')[0];
|
||||
const dateFrom =
|
||||
typeof startDate === 'string'
|
||||
? startDate
|
||||
: new Date(startDate).toISOString().split('T')[0];
|
||||
|
||||
// Fetch balances
|
||||
const balanceResult = await enableBankingService.getBalances(
|
||||
accountId,
|
||||
psuHeaders,
|
||||
);
|
||||
const balances = balanceResult.balances.map(normalizeBalance);
|
||||
|
||||
// Determine starting balance, preferring CLAV balance type
|
||||
let startingBalance = 0;
|
||||
if (balances.length > 0) {
|
||||
const preferredBalance =
|
||||
balances.find(b => b.balanceType === 'CLAV') ?? balances[0];
|
||||
startingBalance = preferredBalance.balanceAmount.amount;
|
||||
}
|
||||
|
||||
// Fetch all paginated transactions
|
||||
const rawTransactions = await enableBankingService.getAllTransactions(
|
||||
accountId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
psuHeaders,
|
||||
);
|
||||
|
||||
const all: ReturnType<typeof normalizeTransaction>[] = [];
|
||||
const booked: ReturnType<typeof normalizeTransaction>[] = [];
|
||||
const pending: ReturnType<typeof normalizeTransaction>[] = [];
|
||||
|
||||
for (const tx of rawTransactions) {
|
||||
const normalized = normalizeTransaction(tx);
|
||||
all.push(normalized);
|
||||
if (normalized.booked) {
|
||||
booked.push(normalized);
|
||||
} else {
|
||||
pending.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
transactions: {
|
||||
all,
|
||||
booked,
|
||||
pending,
|
||||
},
|
||||
balances,
|
||||
startingBalance,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
debug('Error fetching transactions: %s', error);
|
||||
|
||||
// Return structured error codes so the client can show
|
||||
// appropriate UI (e.g. re-auth prompt for expired sessions)
|
||||
if (error instanceof EnableBankingError) {
|
||||
if (error.error_code === 'INVALID_ACCESS_TOKEN') {
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
error_type: 'ITEM_ERROR',
|
||||
error_code: 'ITEM_LOGIN_REQUIRED',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
error_type: error.error_type,
|
||||
error_code: error.error_code,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
error_type: 'INTERNAL_ERROR',
|
||||
error_code: 'INTERNAL_ERROR',
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,406 @@
|
||||
import createDebug from 'debug';
|
||||
|
||||
import {
|
||||
EnableBankingError,
|
||||
handleEnableBankingError,
|
||||
} from '#app-enablebanking/utils/errors';
|
||||
import { getJWT } from '#app-enablebanking/utils/jwt';
|
||||
import { SecretName, secretsService } from '#services/secrets-service';
|
||||
|
||||
const debug = createDebug('actual:enable-banking:service');
|
||||
|
||||
const BASE_URL = 'https://api.enablebanking.com';
|
||||
|
||||
// --- Type definitions ---
|
||||
|
||||
type EnableBankingTransaction = {
|
||||
entry_reference?: string;
|
||||
transaction_id?: string;
|
||||
transaction_amount: { currency: string; amount: string };
|
||||
creditor?: { name?: string };
|
||||
debtor?: { name?: string };
|
||||
credit_debit_indicator?: 'CRDT' | 'DBIT';
|
||||
status?: 'BOOK' | 'PDNG';
|
||||
booking_date?: string;
|
||||
value_date?: string;
|
||||
transaction_date?: string;
|
||||
remittance_information?: string[];
|
||||
};
|
||||
|
||||
type EnableBankingBalance = {
|
||||
balance_amount: { currency: string; amount: string };
|
||||
balance_type: string;
|
||||
reference_date?: string;
|
||||
};
|
||||
|
||||
type EnableBankingSessionAccount = {
|
||||
account_id?: { iban?: string };
|
||||
account_servicer?: { bic_fi?: string; name?: string };
|
||||
name?: string;
|
||||
currency?: string;
|
||||
uid: string;
|
||||
};
|
||||
|
||||
type EnableBankingSession = {
|
||||
session_id: string;
|
||||
accounts: EnableBankingSessionAccount[];
|
||||
aspsp?: { name?: string; country?: string };
|
||||
};
|
||||
|
||||
type EnableBankingAspsp = {
|
||||
name: string;
|
||||
country: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type EnableBankingAuthResponse = {
|
||||
url: string;
|
||||
authorization_id: string;
|
||||
};
|
||||
|
||||
type BankSyncTransaction = {
|
||||
transactionId: string;
|
||||
date: string;
|
||||
bookingDate: string;
|
||||
valueDate?: string;
|
||||
transactionAmount: { amount: string; currency: string };
|
||||
payeeName: string;
|
||||
remittanceInformationUnstructured?: string;
|
||||
booked: boolean;
|
||||
};
|
||||
|
||||
type BankSyncBalance = {
|
||||
balanceAmount: { amount: number; currency: string };
|
||||
balanceType: string;
|
||||
referenceDate?: string;
|
||||
};
|
||||
|
||||
type NormalizedAccount = {
|
||||
account_id: string;
|
||||
name: string;
|
||||
institution: string;
|
||||
currency?: string;
|
||||
iban?: string;
|
||||
};
|
||||
|
||||
// --- PSU headers ---
|
||||
|
||||
export type PsuHeaders = {
|
||||
'Psu-Ip-Address'?: string;
|
||||
'Psu-User-Agent'?: string;
|
||||
};
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
function getCredentials(): { applicationId: string; secretKey: string } {
|
||||
const applicationId = secretsService.get(
|
||||
SecretName.enablebanking_applicationId,
|
||||
);
|
||||
const secretKey = secretsService.get(SecretName.enablebanking_secretKey);
|
||||
|
||||
if (!applicationId || !secretKey) {
|
||||
throw new EnableBankingError(
|
||||
'INVALID_INPUT',
|
||||
'NOT_CONFIGURED',
|
||||
'Enable Banking is not configured',
|
||||
);
|
||||
}
|
||||
|
||||
return { applicationId, secretKey };
|
||||
}
|
||||
|
||||
function getAuthorizationHeader(): string {
|
||||
const { applicationId, secretKey } = getCredentials();
|
||||
const token = getJWT(applicationId, secretKey);
|
||||
return `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000; // 30 seconds
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
authHeaderOverride?: string,
|
||||
psuHeaders?: PsuHeaders,
|
||||
): Promise<T> {
|
||||
const url = `${BASE_URL}${path}`;
|
||||
debug('%s %s', method, url);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: authHeaderOverride ?? getAuthorizationHeader(),
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// Forward PSU headers to signal the end-user is online.
|
||||
// This exempts the request from background data-fetch rate limits
|
||||
// that many ASPSPs enforce (e.g. 4 requests/day).
|
||||
if (psuHeaders) {
|
||||
for (const [key, value] of Object.entries(psuHeaders)) {
|
||||
if (value) {
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
const options: RequestInit = { method, headers, signal: controller.signal };
|
||||
if (body !== undefined) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, options);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new EnableBankingError(
|
||||
'TIMED_OUT',
|
||||
'TIMED_OUT',
|
||||
'Request timed out',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let responseBody: unknown;
|
||||
try {
|
||||
responseBody = await response.json();
|
||||
} catch {
|
||||
responseBody = await response.text().catch(() => 'unknown');
|
||||
}
|
||||
throw handleEnableBankingError(response.status, responseBody);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generic API wrapper, type is validated by caller
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
// --- Normalization functions ---
|
||||
|
||||
export function normalizeTransaction(
|
||||
tx: EnableBankingTransaction,
|
||||
): BankSyncTransaction {
|
||||
const transactionId = tx.entry_reference || tx.transaction_id || '';
|
||||
const bookingDate =
|
||||
tx.booking_date || tx.value_date || tx.transaction_date || '';
|
||||
const valueDate = tx.value_date;
|
||||
|
||||
let payeeName = '';
|
||||
if (tx.credit_debit_indicator === 'CRDT' && tx.debtor?.name) {
|
||||
payeeName = tx.debtor.name;
|
||||
} else if (tx.credit_debit_indicator === 'DBIT' && tx.creditor?.name) {
|
||||
payeeName = tx.creditor.name;
|
||||
} else if (tx.creditor?.name) {
|
||||
payeeName = tx.creditor.name;
|
||||
} else if (tx.debtor?.name) {
|
||||
payeeName = tx.debtor.name;
|
||||
} else if (
|
||||
tx.remittance_information &&
|
||||
tx.remittance_information.length > 0
|
||||
) {
|
||||
payeeName = tx.remittance_information[0];
|
||||
}
|
||||
|
||||
const remittanceInformationUnstructured = tx.remittance_information
|
||||
? tx.remittance_information.join(' ')
|
||||
: undefined;
|
||||
|
||||
// Normalize amount based on credit/debit indicator.
|
||||
// When indicator is present, strip existing sign and apply the correct one.
|
||||
// When absent, preserve the original sign from the bank.
|
||||
const trimmedAmount = tx.transaction_amount.amount.trim();
|
||||
let signedAmount: string;
|
||||
if (tx.credit_debit_indicator === 'DBIT') {
|
||||
signedAmount = '-' + trimmedAmount.replace(/^[+-]/, '');
|
||||
} else if (tx.credit_debit_indicator === 'CRDT') {
|
||||
signedAmount = trimmedAmount.replace(/^[+-]/, '');
|
||||
} else {
|
||||
signedAmount = trimmedAmount;
|
||||
}
|
||||
|
||||
return {
|
||||
transactionId,
|
||||
date: bookingDate,
|
||||
bookingDate,
|
||||
valueDate,
|
||||
transactionAmount: {
|
||||
amount: signedAmount,
|
||||
currency: tx.transaction_amount.currency,
|
||||
},
|
||||
payeeName,
|
||||
remittanceInformationUnstructured,
|
||||
booked: tx.status !== 'PDNG',
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeBalance(bal: EnableBankingBalance): BankSyncBalance {
|
||||
const amount = Math.round(parseFloat(bal.balance_amount.amount) * 100);
|
||||
return {
|
||||
balanceAmount: {
|
||||
amount,
|
||||
currency: bal.balance_amount.currency,
|
||||
},
|
||||
balanceType: bal.balance_type,
|
||||
referenceDate: bal.reference_date,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAccount(
|
||||
account: EnableBankingSessionAccount,
|
||||
aspsp?: { name?: string },
|
||||
): NormalizedAccount {
|
||||
return {
|
||||
account_id: account.uid,
|
||||
name: account.name || account.account_id?.iban || account.uid,
|
||||
institution: aspsp?.name || account.account_servicer?.name || 'Unknown',
|
||||
currency: account.currency,
|
||||
iban: account.account_id?.iban,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Service ---
|
||||
|
||||
export const enableBankingService = {
|
||||
isConfigured(): boolean {
|
||||
const applicationId = secretsService.get(
|
||||
SecretName.enablebanking_applicationId,
|
||||
);
|
||||
const secretKey = secretsService.get(SecretName.enablebanking_secretKey);
|
||||
return !!(applicationId && secretKey);
|
||||
},
|
||||
|
||||
async validateCredentials(
|
||||
applicationId: string,
|
||||
secretKey: string,
|
||||
): Promise<unknown> {
|
||||
const token = getJWT(applicationId, secretKey);
|
||||
return request<unknown>(
|
||||
'GET',
|
||||
'/application',
|
||||
undefined,
|
||||
`Bearer ${token}`,
|
||||
);
|
||||
},
|
||||
|
||||
async getApplication(): Promise<unknown> {
|
||||
return request<unknown>('GET', '/application');
|
||||
},
|
||||
|
||||
async getAspsps(country?: string): Promise<EnableBankingAspsp[]> {
|
||||
const query = country ? `?country=${encodeURIComponent(country)}` : '';
|
||||
return request<EnableBankingAspsp[]>('GET', `/aspsps${query}`);
|
||||
},
|
||||
|
||||
async startAuth(
|
||||
aspsp: { name: string; country: string },
|
||||
redirectUrl: string,
|
||||
state: string,
|
||||
maxConsentValidity?: number,
|
||||
): Promise<EnableBankingAuthResponse> {
|
||||
const DEFAULT_CONSENT_DAYS = 90;
|
||||
const defaultMs = DEFAULT_CONSENT_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Respect the ASPSP's maximum_consent_validity (in seconds) if provided,
|
||||
// capping at our default of 90 days.
|
||||
const consentMs =
|
||||
maxConsentValidity != null && maxConsentValidity > 0
|
||||
? Math.min(maxConsentValidity * 1000, defaultMs)
|
||||
: defaultMs;
|
||||
|
||||
const validUntil = new Date(Date.now() + consentMs);
|
||||
|
||||
return request<EnableBankingAuthResponse>('POST', '/auth', {
|
||||
aspsp: { name: aspsp.name, country: aspsp.country },
|
||||
redirect_url: redirectUrl,
|
||||
state,
|
||||
access: {
|
||||
valid_until: validUntil.toISOString(),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async createSession(code: string): Promise<EnableBankingSession> {
|
||||
return request<EnableBankingSession>('POST', '/sessions', { code });
|
||||
},
|
||||
|
||||
async getSession(sessionId: string): Promise<EnableBankingSession> {
|
||||
return request<EnableBankingSession>(
|
||||
'GET',
|
||||
`/sessions/${encodeURIComponent(sessionId)}`,
|
||||
);
|
||||
},
|
||||
|
||||
async getBalances(
|
||||
accountUid: string,
|
||||
psuHeaders?: PsuHeaders,
|
||||
): Promise<{ balances: EnableBankingBalance[] }> {
|
||||
return request<{ balances: EnableBankingBalance[] }>(
|
||||
'GET',
|
||||
`/accounts/${encodeURIComponent(accountUid)}/balances`,
|
||||
undefined,
|
||||
undefined,
|
||||
psuHeaders,
|
||||
);
|
||||
},
|
||||
|
||||
async getTransactions(
|
||||
accountUid: string,
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
continuationKey?: string,
|
||||
psuHeaders?: PsuHeaders,
|
||||
): Promise<{
|
||||
transactions: EnableBankingTransaction[];
|
||||
continuation_key?: string;
|
||||
}> {
|
||||
let path = `/accounts/${encodeURIComponent(accountUid)}/transactions?date_from=${encodeURIComponent(dateFrom)}&date_to=${encodeURIComponent(dateTo)}`;
|
||||
if (continuationKey) {
|
||||
path += `&continuation_key=${encodeURIComponent(continuationKey)}`;
|
||||
}
|
||||
return request<{
|
||||
transactions: EnableBankingTransaction[];
|
||||
continuation_key?: string;
|
||||
}>('GET', path, undefined, undefined, psuHeaders);
|
||||
},
|
||||
|
||||
async getAllTransactions(
|
||||
accountUid: string,
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
psuHeaders?: PsuHeaders,
|
||||
): Promise<EnableBankingTransaction[]> {
|
||||
const allTransactions: EnableBankingTransaction[] = [];
|
||||
let continuationKey: string | undefined;
|
||||
const maxIterations = 100;
|
||||
let iteration = 0;
|
||||
|
||||
do {
|
||||
const result = await enableBankingService.getTransactions(
|
||||
accountUid,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
continuationKey,
|
||||
psuHeaders,
|
||||
);
|
||||
allTransactions.push(...result.transactions);
|
||||
|
||||
if (
|
||||
result.continuation_key &&
|
||||
result.continuation_key === continuationKey
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
continuationKey = result.continuation_key;
|
||||
iteration++;
|
||||
} while (continuationKey && iteration < maxIterations);
|
||||
|
||||
return allTransactions;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,566 @@
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
|
||||
import { enableBankingService } from '#app-enablebanking/services/enablebanking-service';
|
||||
import { EnableBankingError } from '#app-enablebanking/utils/errors';
|
||||
import { secretsService } from '#services/secrets-service';
|
||||
|
||||
import {
|
||||
mockAspspList,
|
||||
mockAuthResponse,
|
||||
mockBalance,
|
||||
mockCreditTransaction,
|
||||
mockDebitTransaction,
|
||||
mockSession,
|
||||
mockSessionAccount,
|
||||
} from './fixtures';
|
||||
|
||||
// Mock dependencies before importing the service
|
||||
vi.mock('../../../services/secrets-service', () => ({
|
||||
SecretName: {
|
||||
enablebanking_applicationId: 'enablebanking_applicationId',
|
||||
enablebanking_secretKey: 'enablebanking_secretKey',
|
||||
},
|
||||
secretsService: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/jwt', () => ({
|
||||
getJWT: vi.fn(() => 'mock-jwt-token'),
|
||||
}));
|
||||
|
||||
// Mock global fetch
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
function mockFetchResponse(data: unknown, ok = true, status = 200) {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok,
|
||||
status,
|
||||
json: () => Promise.resolve(data),
|
||||
text: () => Promise.resolve(JSON.stringify(data)),
|
||||
});
|
||||
}
|
||||
|
||||
describe('enableBankingService', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(secretsService.get).mockImplementation((name: string) => {
|
||||
if (name === 'enablebanking_applicationId') return 'test-app-id';
|
||||
if (name === 'enablebanking_secretKey') return 'test-secret-key';
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('#isConfigured', () => {
|
||||
it('returns true when both credentials are set', () => {
|
||||
expect(enableBankingService.isConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when applicationId is missing', () => {
|
||||
vi.mocked(secretsService.get).mockImplementation((name: string) => {
|
||||
if (name === 'enablebanking_secretKey') return 'test-secret-key';
|
||||
return null;
|
||||
});
|
||||
expect(enableBankingService.isConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when secretKey is missing', () => {
|
||||
vi.mocked(secretsService.get).mockImplementation((name: string) => {
|
||||
if (name === 'enablebanking_applicationId') return 'test-app-id';
|
||||
return null;
|
||||
});
|
||||
expect(enableBankingService.isConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when both credentials are missing', () => {
|
||||
vi.mocked(secretsService.get).mockReturnValue(null);
|
||||
expect(enableBankingService.isConfigured()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getApplication', () => {
|
||||
it('calls GET /application with auth header', async () => {
|
||||
mockFetchResponse({ name: 'Test App', status: 'active' });
|
||||
|
||||
const result = await enableBankingService.getApplication();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.enablebanking.com/application',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer mock-jwt-token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ name: 'Test App', status: 'active' });
|
||||
});
|
||||
|
||||
it('throws EnableBankingError on non-ok response', async () => {
|
||||
mockFetchResponse({ message: 'Unauthorized' }, false, 401);
|
||||
|
||||
await expect(enableBankingService.getApplication()).rejects.toThrow(
|
||||
EnableBankingError,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when credentials are not configured', async () => {
|
||||
vi.mocked(secretsService.get).mockReturnValue(null);
|
||||
|
||||
await expect(enableBankingService.getApplication()).rejects.toThrow(
|
||||
'Enable Banking is not configured',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getAspsps', () => {
|
||||
it('fetches ASPSPs for a specific country', async () => {
|
||||
mockFetchResponse(mockAspspList);
|
||||
|
||||
const result = await enableBankingService.getAspsps('FI');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.enablebanking.com/aspsps?country=FI',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
);
|
||||
expect(result).toEqual(mockAspspList);
|
||||
});
|
||||
|
||||
it('fetches all ASPSPs when no country specified', async () => {
|
||||
mockFetchResponse(mockAspspList);
|
||||
|
||||
await enableBankingService.getAspsps();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.enablebanking.com/aspsps',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes country parameter', async () => {
|
||||
mockFetchResponse([]);
|
||||
|
||||
await enableBankingService.getAspsps('F I');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.enablebanking.com/aspsps?country=F%20I',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#startAuth', () => {
|
||||
it('sends POST /auth with aspsp, redirect_url, state, and access', async () => {
|
||||
mockFetchResponse(mockAuthResponse);
|
||||
|
||||
const result = await enableBankingService.startAuth(
|
||||
{ name: 'Nordea', country: 'FI' },
|
||||
'https://app.example.com/callback',
|
||||
'test-state-uuid',
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.enablebanking.com/auth',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('"aspsp"'),
|
||||
}),
|
||||
);
|
||||
|
||||
const body = JSON.parse(String(mockFetch.mock.calls[0][1].body));
|
||||
expect(body.aspsp).toEqual({ name: 'Nordea', country: 'FI' });
|
||||
expect(body.redirect_url).toBe('https://app.example.com/callback');
|
||||
expect(body.state).toBe('test-state-uuid');
|
||||
expect(body.access.valid_until).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/,
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockAuthResponse);
|
||||
});
|
||||
|
||||
it('sets access.valid_until to 90 days from now', async () => {
|
||||
mockFetchResponse(mockAuthResponse);
|
||||
|
||||
await enableBankingService.startAuth(
|
||||
{ name: 'Nordea', country: 'FI' },
|
||||
'https://app.example.com/callback',
|
||||
'state',
|
||||
);
|
||||
|
||||
const body = JSON.parse(String(mockFetch.mock.calls[0][1].body));
|
||||
const validUntil = new Date(body.access.valid_until);
|
||||
const now = new Date();
|
||||
const diffDays = Math.round(
|
||||
(validUntil.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
expect(diffDays).toBeGreaterThanOrEqual(89);
|
||||
expect(diffDays).toBeLessThanOrEqual(91);
|
||||
});
|
||||
|
||||
it('caps consent at maxConsentValidity when shorter than 90 days', async () => {
|
||||
mockFetchResponse(mockAuthResponse);
|
||||
|
||||
const thirtyDaysInSeconds = 30 * 24 * 60 * 60;
|
||||
await enableBankingService.startAuth(
|
||||
{ name: 'Nordea', country: 'FI' },
|
||||
'https://app.example.com/callback',
|
||||
'state',
|
||||
thirtyDaysInSeconds,
|
||||
);
|
||||
|
||||
const body = JSON.parse(String(mockFetch.mock.calls[0][1].body));
|
||||
const validUntil = new Date(body.access.valid_until);
|
||||
const diffDays = Math.round(
|
||||
(validUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
expect(diffDays).toBeGreaterThanOrEqual(29);
|
||||
expect(diffDays).toBeLessThanOrEqual(31);
|
||||
});
|
||||
|
||||
it('caps consent at 90 days when maxConsentValidity exceeds it', async () => {
|
||||
mockFetchResponse(mockAuthResponse);
|
||||
|
||||
const oneYearInSeconds = 365 * 24 * 60 * 60;
|
||||
await enableBankingService.startAuth(
|
||||
{ name: 'Nordea', country: 'FI' },
|
||||
'https://app.example.com/callback',
|
||||
'state',
|
||||
oneYearInSeconds,
|
||||
);
|
||||
|
||||
const body = JSON.parse(String(mockFetch.mock.calls[0][1].body));
|
||||
const validUntil = new Date(body.access.valid_until);
|
||||
const diffDays = Math.round(
|
||||
(validUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
expect(diffDays).toBeGreaterThanOrEqual(89);
|
||||
expect(diffDays).toBeLessThanOrEqual(91);
|
||||
});
|
||||
|
||||
it('falls back to 90 days when maxConsentValidity is 0', async () => {
|
||||
mockFetchResponse(mockAuthResponse);
|
||||
|
||||
await enableBankingService.startAuth(
|
||||
{ name: 'Nordea', country: 'FI' },
|
||||
'https://app.example.com/callback',
|
||||
'state',
|
||||
0,
|
||||
);
|
||||
|
||||
const body = JSON.parse(String(mockFetch.mock.calls[0][1].body));
|
||||
const validUntil = new Date(body.access.valid_until);
|
||||
const diffDays = Math.round(
|
||||
(validUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
expect(diffDays).toBeGreaterThanOrEqual(89);
|
||||
expect(diffDays).toBeLessThanOrEqual(91);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#createSession', () => {
|
||||
it('sends POST /sessions with code', async () => {
|
||||
mockFetchResponse(mockSession);
|
||||
|
||||
const result = await enableBankingService.createSession('auth-code-123');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.enablebanking.com/sessions',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code: 'auth-code-123' }),
|
||||
}),
|
||||
);
|
||||
expect(result.session_id).toBe('test-session-id');
|
||||
expect(result.accounts).toHaveLength(1);
|
||||
expect(result.accounts[0].uid).toBe(mockSessionAccount.uid);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getSession', () => {
|
||||
it('sends GET /sessions/{sessionId}', async () => {
|
||||
mockFetchResponse(mockSession);
|
||||
|
||||
const result = await enableBankingService.getSession('test-session-id');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.enablebanking.com/sessions/test-session-id',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
);
|
||||
expect(result.session_id).toBe('test-session-id');
|
||||
});
|
||||
|
||||
it('encodes sessionId in URL', async () => {
|
||||
mockFetchResponse(mockSession);
|
||||
|
||||
await enableBankingService.getSession('session/with special');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.enablebanking.com/sessions/session%2Fwith%20special',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getBalances', () => {
|
||||
it('sends GET /accounts/{uid}/balances', async () => {
|
||||
mockFetchResponse({ balances: [mockBalance] });
|
||||
|
||||
const result = await enableBankingService.getBalances(
|
||||
mockSessionAccount.uid,
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
`https://api.enablebanking.com/accounts/${mockSessionAccount.uid}/balances`,
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
);
|
||||
expect(result.balances).toHaveLength(1);
|
||||
expect(result.balances[0].balance_amount.amount).toBe('1234.56');
|
||||
});
|
||||
|
||||
it('forwards PSU headers when provided', async () => {
|
||||
mockFetchResponse({ balances: [mockBalance] });
|
||||
|
||||
await enableBankingService.getBalances(mockSessionAccount.uid, {
|
||||
'Psu-Ip-Address': '192.168.1.1',
|
||||
'Psu-User-Agent': 'Mozilla/5.0',
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Psu-Ip-Address': '192.168.1.1',
|
||||
'Psu-User-Agent': 'Mozilla/5.0',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits PSU headers when not provided', async () => {
|
||||
mockFetchResponse({ balances: [] });
|
||||
|
||||
await enableBankingService.getBalances(mockSessionAccount.uid);
|
||||
|
||||
const headers = mockFetch.mock.calls[0][1].headers;
|
||||
expect(headers).not.toHaveProperty('Psu-Ip-Address');
|
||||
expect(headers).not.toHaveProperty('Psu-User-Agent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getTransactions', () => {
|
||||
it('sends GET /accounts/{uid}/transactions with date params', async () => {
|
||||
mockFetchResponse({
|
||||
transactions: [mockCreditTransaction],
|
||||
});
|
||||
|
||||
const result = await enableBankingService.getTransactions(
|
||||
mockSessionAccount.uid,
|
||||
'2026-01-01',
|
||||
'2026-03-25',
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`/accounts/${mockSessionAccount.uid}/transactions?date_from=2026-01-01&date_to=2026-03-25`,
|
||||
),
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
);
|
||||
expect(result.transactions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('includes continuation_key when provided', async () => {
|
||||
mockFetchResponse({ transactions: [] });
|
||||
|
||||
await enableBankingService.getTransactions(
|
||||
'uid',
|
||||
'2026-01-01',
|
||||
'2026-03-25',
|
||||
'page2-key',
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('continuation_key=page2-key'),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns continuation_key from response', async () => {
|
||||
mockFetchResponse({
|
||||
transactions: [mockCreditTransaction],
|
||||
continuation_key: 'next-page',
|
||||
});
|
||||
|
||||
const result = await enableBankingService.getTransactions(
|
||||
'uid',
|
||||
'2026-01-01',
|
||||
'2026-03-25',
|
||||
);
|
||||
|
||||
expect(result.continuation_key).toBe('next-page');
|
||||
});
|
||||
|
||||
it('forwards PSU headers when provided', async () => {
|
||||
mockFetchResponse({ transactions: [] });
|
||||
|
||||
await enableBankingService.getTransactions(
|
||||
'uid',
|
||||
'2026-01-01',
|
||||
'2026-03-25',
|
||||
undefined,
|
||||
{ 'Psu-Ip-Address': '10.0.0.1' },
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Psu-Ip-Address': '10.0.0.1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getAllTransactions', () => {
|
||||
it('fetches all pages until no continuation_key', async () => {
|
||||
mockFetchResponse({
|
||||
transactions: [mockCreditTransaction],
|
||||
continuation_key: 'page2',
|
||||
});
|
||||
mockFetchResponse({
|
||||
transactions: [mockDebitTransaction],
|
||||
continuation_key: 'page3',
|
||||
});
|
||||
mockFetchResponse({
|
||||
transactions: [mockCreditTransaction],
|
||||
// no continuation_key — last page
|
||||
});
|
||||
|
||||
const result = await enableBankingService.getAllTransactions(
|
||||
'uid',
|
||||
'2026-01-01',
|
||||
'2026-03-25',
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('handles single page response', async () => {
|
||||
mockFetchResponse({
|
||||
transactions: [mockCreditTransaction, mockDebitTransaction],
|
||||
});
|
||||
|
||||
const result = await enableBankingService.getAllTransactions(
|
||||
'uid',
|
||||
'2026-01-01',
|
||||
'2026-03-25',
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handles empty transaction list', async () => {
|
||||
mockFetchResponse({ transactions: [] });
|
||||
|
||||
const result = await enableBankingService.getAllTransactions(
|
||||
'uid',
|
||||
'2026-01-01',
|
||||
'2026-03-25',
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('breaks out of pagination when continuation_key repeats', async () => {
|
||||
mockFetchResponse({
|
||||
transactions: [mockCreditTransaction],
|
||||
continuation_key: 'stuck-key',
|
||||
});
|
||||
mockFetchResponse({
|
||||
transactions: [mockDebitTransaction],
|
||||
continuation_key: 'stuck-key',
|
||||
});
|
||||
|
||||
const result = await enableBankingService.getAllTransactions(
|
||||
'uid',
|
||||
'2026-01-01',
|
||||
'2026-03-25',
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws EnableBankingError on 401', async () => {
|
||||
mockFetchResponse({ message: 'Unauthorized' }, false, 401);
|
||||
|
||||
await expect(enableBankingService.getAspsps('FI')).rejects.toThrow(
|
||||
EnableBankingError,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws EnableBankingError on 429 rate limit', async () => {
|
||||
mockFetchResponse({ message: 'Rate limit exceeded' }, false, 429);
|
||||
|
||||
await expect(enableBankingService.getAspsps('FI')).rejects.toThrow(
|
||||
EnableBankingError,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws EnableBankingError on 500 server error', async () => {
|
||||
mockFetchResponse({ message: 'Internal error' }, false, 500);
|
||||
|
||||
await expect(enableBankingService.getApplication()).rejects.toThrow(
|
||||
EnableBankingError,
|
||||
);
|
||||
});
|
||||
|
||||
it('handles non-JSON error response gracefully', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 502,
|
||||
json: () => Promise.reject(new Error('not json')),
|
||||
text: () => Promise.resolve('Bad Gateway'),
|
||||
});
|
||||
|
||||
await expect(enableBankingService.getApplication()).rejects.toThrow(
|
||||
EnableBankingError,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws TIMED_OUT EnableBankingError on AbortError', async () => {
|
||||
const abortError = new Error('The operation was aborted');
|
||||
abortError.name = 'AbortError';
|
||||
mockFetch.mockRejectedValueOnce(abortError);
|
||||
|
||||
await expect(enableBankingService.getApplication()).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
name: 'EnableBankingError',
|
||||
error_type: 'TIMED_OUT',
|
||||
error_code: 'TIMED_OUT',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
export const mockAspsp = {
|
||||
name: 'Nordea',
|
||||
country: 'FI',
|
||||
logo: 'https://enablebanking.com/brands/FI/Nordea/',
|
||||
psu_types: ['personal'],
|
||||
beta: false,
|
||||
};
|
||||
|
||||
export const mockAspspList = [
|
||||
mockAspsp,
|
||||
{
|
||||
name: 'OP Financial Group',
|
||||
country: 'FI',
|
||||
logo: null,
|
||||
psu_types: ['personal'],
|
||||
beta: false,
|
||||
},
|
||||
{
|
||||
name: 'Revolut',
|
||||
country: 'FI',
|
||||
logo: null,
|
||||
psu_types: ['personal'],
|
||||
beta: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockSessionAccount = {
|
||||
account_id: { iban: 'FI0455231152453547' },
|
||||
account_servicer: { bic_fi: 'NDEAFIHH', name: 'Nordea' },
|
||||
name: 'Current Account',
|
||||
currency: 'EUR',
|
||||
uid: '07cc67f4-45d6-494b-adac-09b5cbc7e2b5',
|
||||
identification_hash: 'abc123',
|
||||
};
|
||||
|
||||
export const mockSessionAccountNoName = {
|
||||
account_id: { iban: 'FI9876543210000001' },
|
||||
account_servicer: { bic_fi: 'OKOYFIHH', name: 'OP' },
|
||||
name: undefined,
|
||||
currency: 'EUR',
|
||||
uid: '12345678-1234-1234-1234-123456789abc',
|
||||
};
|
||||
|
||||
export const mockSessionAccountMinimal = {
|
||||
account_id: {},
|
||||
uid: 'aaaabbbb-cccc-dddd-eeee-ffff00001111',
|
||||
};
|
||||
|
||||
export const mockSession = {
|
||||
session_id: 'test-session-id',
|
||||
accounts: [mockSessionAccount],
|
||||
aspsp: { name: 'Nordea', country: 'FI' },
|
||||
access: { valid_until: '2026-06-24T00:00:00Z' },
|
||||
};
|
||||
|
||||
export const mockAuthResponse = {
|
||||
url: 'https://enablebanking.com/auth/redirect?session=abc',
|
||||
authorization_id: 'auth-id-123',
|
||||
};
|
||||
|
||||
export const mockCreditTransaction = {
|
||||
entry_reference: 'ref-001',
|
||||
transaction_id: 'tx-001',
|
||||
transaction_amount: { currency: 'EUR', amount: '100.50' },
|
||||
creditor: { name: 'Salary Inc' },
|
||||
debtor: { name: 'My Employer' },
|
||||
credit_debit_indicator: 'CRDT' as const,
|
||||
status: 'BOOK' as const,
|
||||
booking_date: '2026-03-01',
|
||||
value_date: '2026-03-01',
|
||||
remittance_information: ['Monthly salary', 'March 2026'],
|
||||
};
|
||||
|
||||
export const mockDebitTransaction = {
|
||||
entry_reference: 'ref-002',
|
||||
transaction_amount: { currency: 'EUR', amount: '-25.99' },
|
||||
creditor: { name: 'Grocery Store' },
|
||||
debtor: { name: 'My Account' },
|
||||
credit_debit_indicator: 'DBIT' as const,
|
||||
status: 'BOOK' as const,
|
||||
booking_date: '2026-03-02',
|
||||
value_date: '2026-03-02',
|
||||
remittance_information: ['Groceries purchase'],
|
||||
};
|
||||
|
||||
export const mockPendingTransaction = {
|
||||
transaction_id: 'tx-003',
|
||||
transaction_amount: { currency: 'EUR', amount: '-10.00' },
|
||||
status: 'PDNG' as const,
|
||||
value_date: '2026-03-03',
|
||||
remittance_information: ['Card payment'],
|
||||
};
|
||||
|
||||
export const mockTransactionNoPayee = {
|
||||
entry_reference: 'ref-004',
|
||||
transaction_amount: { currency: 'EUR', amount: '5.00' },
|
||||
status: 'BOOK' as const,
|
||||
booking_date: '2026-03-04',
|
||||
remittance_information: ['Transfer from savings'],
|
||||
};
|
||||
|
||||
export const mockTransactionMinimal = {
|
||||
transaction_amount: { currency: 'EUR', amount: '1.23' },
|
||||
status: 'BOOK' as const,
|
||||
};
|
||||
|
||||
export const mockBalance = {
|
||||
balance_amount: { currency: 'EUR', amount: '1234.56' },
|
||||
balance_type: 'CLAV',
|
||||
reference_date: '2026-03-24',
|
||||
};
|
||||
|
||||
export const mockNegativeBalance = {
|
||||
balance_amount: { currency: 'EUR', amount: '-50.75' },
|
||||
balance_type: 'XPCD',
|
||||
reference_date: '2026-03-24',
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
normalizeAccount,
|
||||
normalizeBalance,
|
||||
normalizeTransaction,
|
||||
} from '#app-enablebanking/services/enablebanking-service';
|
||||
|
||||
import {
|
||||
mockBalance,
|
||||
mockCreditTransaction,
|
||||
mockDebitTransaction,
|
||||
mockNegativeBalance,
|
||||
mockPendingTransaction,
|
||||
mockSessionAccount,
|
||||
mockSessionAccountMinimal,
|
||||
mockSessionAccountNoName,
|
||||
mockTransactionMinimal,
|
||||
mockTransactionNoPayee,
|
||||
} from './fixtures';
|
||||
|
||||
describe('normalizeTransaction', () => {
|
||||
it('should use debtor name for CRDT transactions', () => {
|
||||
const result = normalizeTransaction(mockCreditTransaction);
|
||||
expect(result.payeeName).toBe('My Employer');
|
||||
expect(result.booked).toBe(true);
|
||||
expect(result.transactionId).toBe('ref-001');
|
||||
expect(result.bookingDate).toBe('2026-03-01');
|
||||
expect(result.valueDate).toBe('2026-03-01');
|
||||
expect(result.transactionAmount).toEqual({
|
||||
amount: '100.50',
|
||||
currency: 'EUR',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use creditor name for DBIT transactions', () => {
|
||||
const result = normalizeTransaction(mockDebitTransaction);
|
||||
expect(result.payeeName).toBe('Grocery Store');
|
||||
expect(result.booked).toBe(true);
|
||||
expect(result.transactionId).toBe('ref-002');
|
||||
expect(result.transactionAmount.amount).toBe('-25.99');
|
||||
});
|
||||
|
||||
it('should mark PDNG transactions as not booked', () => {
|
||||
const result = normalizeTransaction(mockPendingTransaction);
|
||||
expect(result.booked).toBe(false);
|
||||
expect(result.transactionId).toBe('tx-003');
|
||||
});
|
||||
|
||||
it('should preserve original sign when credit_debit_indicator is absent', () => {
|
||||
const result = normalizeTransaction(mockPendingTransaction);
|
||||
expect(result.transactionAmount.amount).toBe('-10.00');
|
||||
});
|
||||
|
||||
it('should fall back to remittance_information for payee when no creditor/debtor', () => {
|
||||
const result = normalizeTransaction(mockTransactionNoPayee);
|
||||
expect(result.payeeName).toBe('Transfer from savings');
|
||||
});
|
||||
|
||||
it('should join remittance_information with space', () => {
|
||||
const result = normalizeTransaction(mockCreditTransaction);
|
||||
expect(result.remittanceInformationUnstructured).toBe(
|
||||
'Monthly salary March 2026',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle minimal transaction with empty payee', () => {
|
||||
const result = normalizeTransaction(mockTransactionMinimal);
|
||||
expect(result.payeeName).toBe('');
|
||||
expect(result.transactionId).toBe('');
|
||||
expect(result.bookingDate).toBe('');
|
||||
expect(result.booked).toBe(true);
|
||||
});
|
||||
|
||||
it('should prefer entry_reference over transaction_id', () => {
|
||||
const result = normalizeTransaction(mockCreditTransaction);
|
||||
expect(result.transactionId).toBe('ref-001');
|
||||
});
|
||||
|
||||
it('should fall back booking_date to value_date then transaction_date', () => {
|
||||
const noBookingDate = {
|
||||
...mockCreditTransaction,
|
||||
booking_date: undefined,
|
||||
};
|
||||
expect(normalizeTransaction(noBookingDate).bookingDate).toBe('2026-03-01'); // falls back to value_date
|
||||
|
||||
const noBookingOrValueDate = {
|
||||
...mockCreditTransaction,
|
||||
booking_date: undefined,
|
||||
value_date: undefined,
|
||||
transaction_date: '2026-02-28',
|
||||
};
|
||||
expect(normalizeTransaction(noBookingOrValueDate).bookingDate).toBe(
|
||||
'2026-02-28',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeBalance', () => {
|
||||
it('should convert string amount to integer cents', () => {
|
||||
const result = normalizeBalance(mockBalance);
|
||||
expect(result.balanceAmount.amount).toBe(123456);
|
||||
expect(result.balanceAmount.currency).toBe('EUR');
|
||||
expect(result.balanceType).toBe('CLAV');
|
||||
expect(result.referenceDate).toBe('2026-03-24');
|
||||
});
|
||||
|
||||
it('should handle negative amounts', () => {
|
||||
const result = normalizeBalance(mockNegativeBalance);
|
||||
expect(result.balanceAmount.amount).toBe(-5075);
|
||||
expect(result.balanceType).toBe('XPCD');
|
||||
});
|
||||
|
||||
it('should handle whole numbers', () => {
|
||||
const result = normalizeBalance({
|
||||
balance_amount: { currency: 'EUR', amount: '100' },
|
||||
balance_type: 'CLAV',
|
||||
});
|
||||
expect(result.balanceAmount.amount).toBe(10000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeAccount', () => {
|
||||
it('should use uid as account_id', () => {
|
||||
const result = normalizeAccount(mockSessionAccount);
|
||||
expect(result.account_id).toBe('07cc67f4-45d6-494b-adac-09b5cbc7e2b5');
|
||||
});
|
||||
|
||||
it('should use name when available and aspsp name for institution', () => {
|
||||
const result = normalizeAccount(mockSessionAccount, { name: 'Nordea' });
|
||||
expect(result.name).toBe('Current Account');
|
||||
expect(result.institution).toBe('Nordea');
|
||||
});
|
||||
|
||||
it('should fall back to iban when name is missing', () => {
|
||||
const result = normalizeAccount(mockSessionAccountNoName);
|
||||
expect(result.name).toBe('FI9876543210000001');
|
||||
});
|
||||
|
||||
it('should fall back to uid when both name and iban are missing', () => {
|
||||
const result = normalizeAccount(mockSessionAccountMinimal);
|
||||
expect(result.name).toBe('aaaabbbb-cccc-dddd-eeee-ffff00001111');
|
||||
});
|
||||
|
||||
it('should fall back to account_servicer name for institution', () => {
|
||||
const result = normalizeAccount(mockSessionAccount);
|
||||
expect(result.institution).toBe('Nordea'); // from account_servicer
|
||||
});
|
||||
|
||||
it('should use Unknown when no institution info available', () => {
|
||||
const result = normalizeAccount(mockSessionAccountMinimal);
|
||||
expect(result.institution).toBe('Unknown');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock all external dependencies before importing the app
|
||||
vi.mock('../../services/secrets-service', () => ({
|
||||
SecretName: {
|
||||
enablebanking_applicationId: 'enablebanking_applicationId',
|
||||
enablebanking_secretKey: 'enablebanking_secretKey',
|
||||
},
|
||||
secretsService: {
|
||||
get: vi.fn(() => 'test-value'),
|
||||
set: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../utils/jwt', () => ({
|
||||
getJWT: vi.fn(() => 'mock-jwt-token'),
|
||||
}));
|
||||
|
||||
vi.mock('../../util/middlewares', () => ({
|
||||
requestLoggerMiddleware: (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
validateSessionMiddleware: (_req: unknown, _res: unknown, next: () => void) =>
|
||||
next(),
|
||||
}));
|
||||
|
||||
vi.mock('../../app-gocardless/util/handle-error', () => ({
|
||||
handleError:
|
||||
(fn: Function) =>
|
||||
(req: unknown, res: { send: (data: unknown) => void }) => {
|
||||
Promise.resolve(fn(req, res)).catch((err: Error) => {
|
||||
res.send({
|
||||
status: 'ok',
|
||||
data: {
|
||||
error_code: 'INTERNAL_ERROR',
|
||||
error_type: err.message || 'internal-error',
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
// We need to dynamically import the handlers after mocks are set up
|
||||
const { handlers } = await import('../app-enablebanking');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/', handlers);
|
||||
|
||||
function mockFetchResponse(data: unknown, ok = true, status = 200) {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok,
|
||||
status,
|
||||
json: () => Promise.resolve(data),
|
||||
text: () => Promise.resolve(JSON.stringify(data)),
|
||||
});
|
||||
}
|
||||
|
||||
describe('Enable Banking Express routes', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('POST /status', () => {
|
||||
it('returns configured: true when secrets are set', async () => {
|
||||
const res = await request(app).post('/status').send({});
|
||||
|
||||
expect(res.body.status).toBe('ok');
|
||||
expect(res.body.data.configured).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /configure', () => {
|
||||
it('returns error when applicationId is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/configure')
|
||||
.send({ secretKey: 'key' });
|
||||
|
||||
expect(res.body.data.error_code).toBe('INVALID_INPUT');
|
||||
});
|
||||
|
||||
it('returns error when secretKey is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/configure')
|
||||
.send({ applicationId: 'id' });
|
||||
|
||||
expect(res.body.data.error_code).toBe('INVALID_INPUT');
|
||||
});
|
||||
|
||||
it('validates credentials by calling getApplication', async () => {
|
||||
mockFetchResponse({ name: 'Test App' });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/configure')
|
||||
.send({ applicationId: 'test-id', secretKey: 'test-key' });
|
||||
|
||||
expect(res.body.data.configured).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.enablebanking.com/application',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns error when getApplication fails', async () => {
|
||||
mockFetchResponse({ message: 'Invalid credentials' }, false, 401);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/configure')
|
||||
.send({ applicationId: 'bad-id', secretKey: 'bad-key' });
|
||||
|
||||
expect(res.body.data.error_code).toBe('CONFIGURATION_FAILED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /aspsps', () => {
|
||||
it('returns ASPSP list for a country', async () => {
|
||||
mockFetchResponse([
|
||||
{ name: 'Nordea', country: 'FI' },
|
||||
{ name: 'OP', country: 'FI' },
|
||||
]);
|
||||
|
||||
const res = await request(app).post('/aspsps').send({ country: 'FI' });
|
||||
|
||||
expect(res.body.status).toBe('ok');
|
||||
expect(res.body.data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handles API errors gracefully', async () => {
|
||||
mockFetchResponse({ message: 'Server error' }, false, 500);
|
||||
|
||||
const res = await request(app).post('/aspsps').send({ country: 'XX' });
|
||||
|
||||
expect(res.body.data.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /start-auth', () => {
|
||||
it('returns error when aspsp is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/start-auth')
|
||||
.send({ redirectUrl: 'https://app.example.com/callback' });
|
||||
|
||||
expect(res.body.data.error_code).toBe('INVALID_INPUT');
|
||||
});
|
||||
|
||||
it('returns error when redirectUrl is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/start-auth')
|
||||
.send({ aspsp: { name: 'Nordea', country: 'FI' } });
|
||||
|
||||
expect(res.body.data.error_code).toBe('INVALID_INPUT');
|
||||
});
|
||||
|
||||
it('returns url and state on success', async () => {
|
||||
mockFetchResponse({
|
||||
url: 'https://enablebanking.com/auth/redirect',
|
||||
authorization_id: 'auth-123',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/start-auth')
|
||||
.send({
|
||||
aspsp: { name: 'Nordea', country: 'FI' },
|
||||
redirectUrl: 'https://app.example.com/callback',
|
||||
});
|
||||
|
||||
expect(res.body.data.url).toBe('https://enablebanking.com/auth/redirect');
|
||||
expect(res.body.data.state).toBeDefined();
|
||||
expect(typeof res.body.data.state).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /complete-auth', () => {
|
||||
it('returns error when code is missing', async () => {
|
||||
const res = await request(app).post('/complete-auth').send({});
|
||||
|
||||
expect(res.body.data.error_code).toBe('INVALID_INPUT');
|
||||
});
|
||||
|
||||
it('creates session and normalizes accounts', async () => {
|
||||
// Mock createSession response
|
||||
mockFetchResponse({
|
||||
session_id: 'session-123',
|
||||
accounts: [
|
||||
{
|
||||
account_id: { iban: 'FI0455231152453547' },
|
||||
account_servicer: { name: 'Nordea' },
|
||||
name: 'Current Account',
|
||||
currency: 'EUR',
|
||||
uid: 'account-uid-1',
|
||||
},
|
||||
],
|
||||
aspsp: { name: 'Nordea', country: 'FI' },
|
||||
});
|
||||
// Mock getBalances for the account
|
||||
mockFetchResponse({
|
||||
balances: [
|
||||
{
|
||||
balance_amount: { currency: 'EUR', amount: '1000.00' },
|
||||
balance_type: 'CLAV',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/complete-auth')
|
||||
.send({ code: 'auth-code-123' });
|
||||
|
||||
expect(res.body.data.session_id).toBe('session-123');
|
||||
expect(res.body.data.accounts).toHaveLength(1);
|
||||
expect(res.body.data.accounts[0].account_id).toBe('account-uid-1');
|
||||
expect(res.body.data.accounts[0].name).toBe('Current Account');
|
||||
expect(res.body.data.accounts[0].institution).toBe('Nordea');
|
||||
});
|
||||
|
||||
it('handles balance fetch failure gracefully per account', async () => {
|
||||
// Mock createSession response with 2 accounts
|
||||
mockFetchResponse({
|
||||
session_id: 'session-123',
|
||||
accounts: [
|
||||
{ uid: 'acct-1', name: 'Account 1' },
|
||||
{ uid: 'acct-2', name: 'Account 2' },
|
||||
],
|
||||
aspsp: { name: 'TestBank' },
|
||||
});
|
||||
// First account balance succeeds
|
||||
mockFetchResponse({
|
||||
balances: [
|
||||
{
|
||||
balance_amount: { currency: 'EUR', amount: '500.00' },
|
||||
balance_type: 'CLAV',
|
||||
},
|
||||
],
|
||||
});
|
||||
// Second account balance fails
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: () => Promise.resolve({ message: 'Not found' }),
|
||||
text: () => Promise.resolve('Not found'),
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/complete-auth')
|
||||
.send({ code: 'auth-code' });
|
||||
|
||||
// Both accounts should be returned, second with empty balances
|
||||
expect(res.body.data.accounts).toHaveLength(2);
|
||||
expect(res.body.data.accounts[0].balances).toHaveLength(1);
|
||||
expect(res.body.data.accounts[1].balances).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /poll-auth and complete-auth coordination', () => {
|
||||
it('poll resolves when complete-auth is called with matching state', async () => {
|
||||
// First start-auth to get a state
|
||||
mockFetchResponse({
|
||||
url: 'https://enablebanking.com/auth',
|
||||
authorization_id: 'auth-1',
|
||||
});
|
||||
|
||||
const startRes = await request(app)
|
||||
.post('/start-auth')
|
||||
.send({
|
||||
aspsp: { name: 'Nordea', country: 'FI' },
|
||||
redirectUrl: 'https://app.example.com/callback',
|
||||
});
|
||||
|
||||
const state = startRes.body.data.state;
|
||||
|
||||
// Start poll-auth (non-blocking) and complete-auth after a short delay
|
||||
const pollPromise = request(app).post('/poll-auth').send({ state });
|
||||
|
||||
// Schedule complete-auth after poll registers
|
||||
const completePromise = new Promise<void>(resolve => {
|
||||
setTimeout(async () => {
|
||||
// Mock createSession response
|
||||
mockFetchResponse({
|
||||
session_id: 'session-abc',
|
||||
accounts: [{ uid: 'uid-1', name: 'Account' }],
|
||||
aspsp: { name: 'Nordea' },
|
||||
});
|
||||
// Mock getBalances for the account
|
||||
mockFetchResponse({
|
||||
balances: [
|
||||
{
|
||||
balance_amount: { currency: 'EUR', amount: '100.00' },
|
||||
balance_type: 'CLAV',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post('/complete-auth')
|
||||
.send({ code: 'the-auth-code', state });
|
||||
|
||||
resolve();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Wait for both to finish
|
||||
const [pollRes] = await Promise.all([pollPromise, completePromise]);
|
||||
|
||||
expect(pollRes.body.status).toBe('ok');
|
||||
expect(pollRes.body.data.session_id).toBe('session-abc');
|
||||
expect(pollRes.body.data.accounts).toHaveLength(1);
|
||||
}, 10000);
|
||||
|
||||
it('poll returns error when state is missing', async () => {
|
||||
const res = await request(app).post('/poll-auth').send({});
|
||||
|
||||
expect(res.body.data.error_code).toBe('INVALID_INPUT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /transactions', () => {
|
||||
it('returns error when accountId is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/transactions')
|
||||
.send({ startDate: '2026-01-01' });
|
||||
|
||||
expect(res.body.data.error_code).toBe('INVALID_INPUT');
|
||||
});
|
||||
|
||||
it('returns error when startDate is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/transactions')
|
||||
.send({ accountId: 'uid-1' });
|
||||
|
||||
expect(res.body.data.error_code).toBe('INVALID_INPUT');
|
||||
});
|
||||
|
||||
it('fetches balances and transactions, returns BankSyncResponse format', async () => {
|
||||
// Mock getBalances
|
||||
mockFetchResponse({
|
||||
balances: [
|
||||
{
|
||||
balance_amount: { currency: 'EUR', amount: '1234.56' },
|
||||
balance_type: 'CLAV',
|
||||
reference_date: '2026-03-24',
|
||||
},
|
||||
],
|
||||
});
|
||||
// Mock getAllTransactions (single page)
|
||||
mockFetchResponse({
|
||||
transactions: [
|
||||
{
|
||||
entry_reference: 'ref-1',
|
||||
transaction_amount: { currency: 'EUR', amount: '100.00' },
|
||||
creditor: { name: 'My Account' },
|
||||
debtor: { name: 'Employer' },
|
||||
credit_debit_indicator: 'CRDT',
|
||||
status: 'BOOK',
|
||||
booking_date: '2026-03-01',
|
||||
value_date: '2026-03-01',
|
||||
},
|
||||
{
|
||||
entry_reference: 'ref-2',
|
||||
transaction_amount: { currency: 'EUR', amount: '-25.00' },
|
||||
creditor: { name: 'Shop' },
|
||||
debtor: { name: 'My Account' },
|
||||
credit_debit_indicator: 'DBIT',
|
||||
status: 'PDNG',
|
||||
value_date: '2026-03-02',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/transactions')
|
||||
.send({ accountId: 'uid-1', startDate: '2026-01-01' });
|
||||
|
||||
expect(res.body.status).toBe('ok');
|
||||
|
||||
const data = res.body.data;
|
||||
expect(data.transactions.all).toHaveLength(2);
|
||||
expect(data.transactions.booked).toHaveLength(1);
|
||||
expect(data.transactions.pending).toHaveLength(1);
|
||||
expect(data.transactions.booked[0].payeeName).toBe('Employer');
|
||||
expect(data.transactions.booked[0].booked).toBe(true);
|
||||
expect(data.transactions.booked[0].transactionAmount.amount).toBe(
|
||||
'100.00',
|
||||
);
|
||||
expect(data.transactions.booked[0].date).toBe('2026-03-01');
|
||||
expect(data.transactions.pending[0].payeeName).toBe('Shop');
|
||||
expect(data.transactions.pending[0].booked).toBe(false);
|
||||
expect(data.transactions.pending[0].transactionAmount.amount).toBe(
|
||||
'-25.00',
|
||||
);
|
||||
expect(data.transactions.pending[0].date).toBe('2026-03-02');
|
||||
|
||||
expect(data.balances).toHaveLength(1);
|
||||
expect(data.balances[0].balanceAmount.amount).toBe(123456);
|
||||
|
||||
expect(data.startingBalance).toBe(123456);
|
||||
});
|
||||
|
||||
it('handles pagination via continuation_key', async () => {
|
||||
// Mock getBalances
|
||||
mockFetchResponse({ balances: [] });
|
||||
// Mock first page
|
||||
mockFetchResponse({
|
||||
transactions: [
|
||||
{
|
||||
entry_reference: 'tx-1',
|
||||
transaction_amount: { currency: 'EUR', amount: '10.00' },
|
||||
status: 'BOOK',
|
||||
},
|
||||
],
|
||||
continuation_key: 'page-2',
|
||||
});
|
||||
// Mock second page
|
||||
mockFetchResponse({
|
||||
transactions: [
|
||||
{
|
||||
entry_reference: 'tx-2',
|
||||
transaction_amount: { currency: 'EUR', amount: '20.00' },
|
||||
status: 'BOOK',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/transactions')
|
||||
.send({ accountId: 'uid-1', startDate: '2026-01-01' });
|
||||
|
||||
expect(res.body.data.transactions.all).toHaveLength(2);
|
||||
// 3 fetch calls: 1 for balances + 2 for paginated transactions
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('returns startingBalance 0 when no balances available', async () => {
|
||||
mockFetchResponse({ balances: [] });
|
||||
mockFetchResponse({ transactions: [] });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/transactions')
|
||||
.send({ accountId: 'uid-1', startDate: '2026-01-01' });
|
||||
|
||||
expect(res.body.data.startingBalance).toBe(0);
|
||||
});
|
||||
|
||||
it('handles API error gracefully', async () => {
|
||||
mockFetchResponse({ message: 'Session expired' }, false, 401);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/transactions')
|
||||
.send({ accountId: 'uid-1', startDate: '2026-01-01' });
|
||||
|
||||
// 401 maps to ITEM_ERROR / ITEM_LOGIN_REQUIRED (expired session)
|
||||
expect(res.body.data.error_type).toBe('ITEM_ERROR');
|
||||
expect(res.body.data.error_code).toBe('ITEM_LOGIN_REQUIRED');
|
||||
});
|
||||
|
||||
it('returns structured error for rate limit (429)', async () => {
|
||||
mockFetchResponse({ message: 'Rate limit exceeded' }, false, 429);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/transactions')
|
||||
.send({ accountId: 'uid-1', startDate: '2026-01-01' });
|
||||
|
||||
expect(res.body.data.error_type).toBe('RATE_LIMIT_EXCEEDED');
|
||||
expect(res.body.data.error_code).toBe('RATE_LIMIT_EXCEEDED');
|
||||
});
|
||||
|
||||
it('forwards PSU headers from the incoming request to the API', async () => {
|
||||
// Mock getBalances
|
||||
mockFetchResponse({ balances: [] });
|
||||
// Mock getTransactions
|
||||
mockFetchResponse({ transactions: [] });
|
||||
|
||||
await request(app)
|
||||
.post('/transactions')
|
||||
.set('X-Forwarded-For', '203.0.113.42, 10.0.0.1')
|
||||
.set('User-Agent', 'TestBrowser/1.0')
|
||||
.send({ accountId: 'uid-1', startDate: '2026-01-01' });
|
||||
|
||||
// Both the balance and transaction fetch calls should include PSU headers
|
||||
for (const call of mockFetch.mock.calls) {
|
||||
expect(call[1].headers).toHaveProperty(
|
||||
'Psu-Ip-Address',
|
||||
'203.0.113.42',
|
||||
);
|
||||
expect(call[1].headers).toHaveProperty(
|
||||
'Psu-User-Agent',
|
||||
'TestBrowser/1.0',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
72
packages/sync-server/src/app-enablebanking/utils/errors.ts
Normal file
72
packages/sync-server/src/app-enablebanking/utils/errors.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import createDebug from 'debug';
|
||||
|
||||
const debug = createDebug('actual:enable-banking:errors');
|
||||
|
||||
export class EnableBankingError extends Error {
|
||||
error_type: string;
|
||||
error_code: string;
|
||||
|
||||
constructor(error_type: string, error_code: string, message?: string) {
|
||||
super(message || `Enable Banking error: ${error_type} - ${error_code}`);
|
||||
this.name = 'EnableBankingError';
|
||||
this.error_type = error_type;
|
||||
this.error_code = error_code;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleEnableBankingError(
|
||||
statusCode: number,
|
||||
body: unknown,
|
||||
): EnableBankingError {
|
||||
const bodyStr =
|
||||
typeof body === 'string' ? body : JSON.stringify(body ?? 'unknown');
|
||||
debug('Enable Banking API error: status=%d body=%s', statusCode, bodyStr);
|
||||
|
||||
const parsed: Record<string, unknown> =
|
||||
typeof body === 'object' && body !== null
|
||||
? Object.fromEntries(Object.entries(body))
|
||||
: {};
|
||||
const message = typeof parsed.message === 'string' ? parsed.message : bodyStr;
|
||||
const errorType = typeof parsed.error === 'string' ? parsed.error : 'UNKNOWN';
|
||||
|
||||
if (statusCode === 401 || statusCode === 403) {
|
||||
return new EnableBankingError(
|
||||
'INVALID_INPUT',
|
||||
'INVALID_ACCESS_TOKEN',
|
||||
message,
|
||||
);
|
||||
}
|
||||
|
||||
if (statusCode === 429) {
|
||||
return new EnableBankingError(
|
||||
'RATE_LIMIT_EXCEEDED',
|
||||
'RATE_LIMIT_EXCEEDED',
|
||||
message,
|
||||
);
|
||||
}
|
||||
|
||||
if (statusCode === 404) {
|
||||
return new EnableBankingError('INVALID_INPUT', 'NOT_FOUND', message);
|
||||
}
|
||||
|
||||
if (statusCode >= 400 && statusCode < 500) {
|
||||
// Check for closed/expired session errors (case-insensitive)
|
||||
const lowerErrorType = (errorType || '').toLowerCase();
|
||||
const lowerMessage = (message || '').toLowerCase();
|
||||
if (
|
||||
lowerErrorType === 'closed_session' ||
|
||||
lowerErrorType === 'expired_session' ||
|
||||
lowerMessage.includes('session') ||
|
||||
lowerMessage.includes('expired')
|
||||
) {
|
||||
return new EnableBankingError(
|
||||
'INVALID_INPUT',
|
||||
'INVALID_ACCESS_TOKEN',
|
||||
message,
|
||||
);
|
||||
}
|
||||
return new EnableBankingError('INVALID_INPUT', 'INVALID_INPUT', message);
|
||||
}
|
||||
|
||||
return new EnableBankingError('INTERNAL_ERROR', 'INTERNAL_ERROR', message);
|
||||
}
|
||||
30
packages/sync-server/src/app-enablebanking/utils/jwt.ts
Normal file
30
packages/sync-server/src/app-enablebanking/utils/jwt.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { sign } from 'jws';
|
||||
import type { Algorithm } from 'jws';
|
||||
|
||||
type Header = { typ: string; alg: Algorithm; kid: string };
|
||||
|
||||
function getJWTHeader(applicationId: string): Header {
|
||||
return { typ: 'JWT', alg: 'RS256', kid: applicationId };
|
||||
}
|
||||
|
||||
function getJWTBody(exp = 3600) {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
return {
|
||||
iss: 'enablebanking.com',
|
||||
aud: 'api.enablebanking.com',
|
||||
iat: timestamp,
|
||||
exp: timestamp + exp,
|
||||
};
|
||||
}
|
||||
|
||||
export function getJWT(
|
||||
applicationId: string,
|
||||
secretKey: string,
|
||||
exp = 3600,
|
||||
): string {
|
||||
return sign({
|
||||
header: getJWTHeader(applicationId),
|
||||
payload: getJWTBody(exp),
|
||||
secret: secretKey,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
EnableBankingError,
|
||||
handleEnableBankingError,
|
||||
} from '#app-enablebanking/utils/errors';
|
||||
|
||||
describe('EnableBankingError', () => {
|
||||
it('should create an error with type and code', () => {
|
||||
const error = new EnableBankingError(
|
||||
'INVALID_INPUT',
|
||||
'MISSING_FIELD',
|
||||
'oops',
|
||||
);
|
||||
expect(error.error_type).toBe('INVALID_INPUT');
|
||||
expect(error.error_code).toBe('MISSING_FIELD');
|
||||
expect(error.message).toBe('oops');
|
||||
expect(error.name).toBe('EnableBankingError');
|
||||
});
|
||||
|
||||
it('should use default message when not provided', () => {
|
||||
const error = new EnableBankingError('INTERNAL_ERROR', 'UNKNOWN');
|
||||
expect(error.message).toBe(
|
||||
'Enable Banking error: INTERNAL_ERROR - UNKNOWN',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleEnableBankingError', () => {
|
||||
it('should return INVALID_ACCESS_TOKEN for 401', () => {
|
||||
const error = handleEnableBankingError(401, { message: 'Unauthorized' });
|
||||
expect(error.error_type).toBe('INVALID_INPUT');
|
||||
expect(error.error_code).toBe('INVALID_ACCESS_TOKEN');
|
||||
});
|
||||
|
||||
it('should return INVALID_ACCESS_TOKEN for 403', () => {
|
||||
const error = handleEnableBankingError(403, { message: 'Forbidden' });
|
||||
expect(error.error_code).toBe('INVALID_ACCESS_TOKEN');
|
||||
});
|
||||
|
||||
it('should return RATE_LIMIT_EXCEEDED for 429', () => {
|
||||
const error = handleEnableBankingError(429, {
|
||||
message: 'Too many requests',
|
||||
});
|
||||
expect(error.error_type).toBe('RATE_LIMIT_EXCEEDED');
|
||||
expect(error.error_code).toBe('RATE_LIMIT_EXCEEDED');
|
||||
});
|
||||
|
||||
it('should return NOT_FOUND for 404', () => {
|
||||
const error = handleEnableBankingError(404, { message: 'Not found' });
|
||||
expect(error.error_code).toBe('NOT_FOUND');
|
||||
});
|
||||
|
||||
it('should detect session-related errors as INVALID_ACCESS_TOKEN', () => {
|
||||
const error = handleEnableBankingError(400, {
|
||||
error: 'CLOSED_SESSION',
|
||||
message: 'session closed',
|
||||
});
|
||||
expect(error.error_code).toBe('INVALID_ACCESS_TOKEN');
|
||||
});
|
||||
|
||||
it('should detect EXPIRED_SESSION as INVALID_ACCESS_TOKEN', () => {
|
||||
const error = handleEnableBankingError(400, {
|
||||
error: 'EXPIRED_SESSION',
|
||||
message: 'expired',
|
||||
});
|
||||
expect(error.error_code).toBe('INVALID_ACCESS_TOKEN');
|
||||
});
|
||||
|
||||
it('should return INTERNAL_ERROR for 500+', () => {
|
||||
const error = handleEnableBankingError(500, { message: 'Server error' });
|
||||
expect(error.error_type).toBe('INTERNAL_ERROR');
|
||||
expect(error.error_code).toBe('INTERNAL_ERROR');
|
||||
});
|
||||
|
||||
it('should handle string body', () => {
|
||||
const error = handleEnableBankingError(500, 'raw error text');
|
||||
expect(error.message).toBe('raw error text');
|
||||
});
|
||||
|
||||
it('should handle null body', () => {
|
||||
const error = handleEnableBankingError(500, null);
|
||||
expect(error).toBeInstanceOf(EnableBankingError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { sign } from 'jws';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getJWT } from '#app-enablebanking/utils/jwt';
|
||||
|
||||
// Mock jws to avoid needing real RSA keys
|
||||
vi.mock('jws', () => ({
|
||||
sign: vi.fn(({ header, payload }) => {
|
||||
return `${JSON.stringify(header)}.${JSON.stringify(payload)}.mock-signature`;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('getJWT', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
it('should call jws.sign with correct header', () => {
|
||||
getJWT('my-app-id', 'my-secret-key');
|
||||
|
||||
expect(sign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
header: {
|
||||
typ: 'JWT',
|
||||
alg: 'RS256',
|
||||
kid: 'my-app-id',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include correct payload fields', () => {
|
||||
getJWT('my-app-id', 'my-secret-key');
|
||||
|
||||
const callArgs = vi.mocked(sign).mock.calls[0][0];
|
||||
const rawPayload = callArgs.payload;
|
||||
const payload: { iss: string; aud: string; iat: number; exp: number } =
|
||||
typeof rawPayload === 'string' ? JSON.parse(rawPayload) : rawPayload;
|
||||
|
||||
expect(payload.iss).toBe('enablebanking.com');
|
||||
expect(payload.aud).toBe('api.enablebanking.com');
|
||||
expect(typeof payload.iat).toBe('number');
|
||||
expect(typeof payload.exp).toBe('number');
|
||||
expect(payload.exp - payload.iat).toBe(3600);
|
||||
});
|
||||
|
||||
it('should use custom expiry', () => {
|
||||
getJWT('my-app-id', 'my-secret-key', 7200);
|
||||
|
||||
const callArgs = vi.mocked(sign).mock.calls[0][0];
|
||||
const rawPayload = callArgs.payload;
|
||||
const payload: { iat: number; exp: number } =
|
||||
typeof rawPayload === 'string' ? JSON.parse(rawPayload) : rawPayload;
|
||||
|
||||
expect(payload.exp - payload.iat).toBe(7200);
|
||||
});
|
||||
|
||||
it('should pass the secret key to jws.sign', () => {
|
||||
getJWT('my-app-id', 'my-secret-key');
|
||||
|
||||
expect(sign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
secret: 'my-secret-key',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a string', () => {
|
||||
const result = getJWT('my-app-id', 'my-secret-key');
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { bootstrap } from './account-db';
|
||||
import * as accountApp from './app-account';
|
||||
import * as adminApp from './app-admin';
|
||||
import * as corsApp from './app-cors-proxy';
|
||||
import * as enableBankingApp from './app-enablebanking/app-enablebanking';
|
||||
import * as goCardlessApp from './app-gocardless/app-gocardless';
|
||||
import * as openidApp from './app-openid';
|
||||
import * as pluggai from './app-pluggyai/app-pluggyai';
|
||||
@@ -59,6 +60,7 @@ app.use('/account', accountApp.handlers);
|
||||
app.use('/gocardless', goCardlessApp.handlers);
|
||||
app.use('/simplefin', simpleFinApp.handlers);
|
||||
app.use('/pluggyai', pluggai.handlers);
|
||||
app.use('/enablebanking', enableBankingApp.handlers);
|
||||
app.use('/secret', secretApp.handlers);
|
||||
|
||||
if (config.get('corsProxy.enabled')) {
|
||||
|
||||
@@ -15,6 +15,8 @@ export const SecretName = {
|
||||
pluggyai_clientId: 'pluggyai_clientId',
|
||||
pluggyai_clientSecret: 'pluggyai_clientSecret',
|
||||
pluggyai_itemIds: 'pluggyai_itemIds',
|
||||
enablebanking_applicationId: 'enablebanking_applicationId',
|
||||
enablebanking_secretKey: 'enablebanking_secretKey',
|
||||
};
|
||||
|
||||
class SecretsDb {
|
||||
|
||||
6
upcoming-release-notes/7345.md
Normal file
6
upcoming-release-notes/7345.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [AurelDemiri]
|
||||
---
|
||||
|
||||
Integrate Enable Banking as a bank sync provider
|
||||
10
yarn.lock
10
yarn.lock
@@ -196,6 +196,7 @@ __metadata:
|
||||
"@types/cors": "npm:^2.8.19"
|
||||
"@types/express": "npm:^5.0.6"
|
||||
"@types/express-actuator": "npm:^1.8.3"
|
||||
"@types/jws": "npm:^3.2.11"
|
||||
"@types/node": "npm:^22.19.15"
|
||||
"@types/supertest": "npm:^7.2.0"
|
||||
"@typescript/native-preview": "npm:^7.0.0-dev.20260309.1"
|
||||
@@ -10445,6 +10446,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/jws@npm:^3.2.11":
|
||||
version: 3.2.11
|
||||
resolution: "@types/jws@npm:3.2.11"
|
||||
dependencies:
|
||||
"@types/node": "npm:*"
|
||||
checksum: 10/968b9069eed4ea09292c8a75692c1319220e29c990a9f952cd61751c0fb0f0062ee44c40e02f05dc4fb709c19d8d612f97ff3e48eed7adc20c605e07d285da1d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/keyv@npm:^3.1.4":
|
||||
version: 3.1.4
|
||||
resolution: "@types/keyv@npm:3.1.4"
|
||||
|
||||
Reference in New Issue
Block a user