Compare commits

...

18 Commits

Author SHA1 Message Date
Matiss Janis Aboltins
beee8ee518 [AI] Add #app-enablebanking subpath imports to sync-server package.json
Register enablebanking service, utils, and root entries in both
the imports and publishConfig.imports maps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:41:27 +01:00
Matiss Janis Aboltins
744cba7a0a [AI] Migrate enable-banking files to subpath imports
Update all enable-banking files to use # subpath imports and
@actual-app/core paths, matching the migration done in master.
Add #enablebanking entry to desktop-client package.json imports map.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:39:45 +01:00
Matiss Janis Aboltins
1f379b6e4c [AI] Merge master into feature/enable-banking, resolve import conflicts
Resolve 3 conflicts caused by master's migration to # path aliases,
keeping enable-banking-specific imports (authorizeEnableBanking,
useEnableBankingStatus, useFeatureFlag, BankSyncProviders type).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:33:09 +01:00
Aurel Demiri
f36a8880bf Merge branch 'actualbudget:master' into feature/enable-banking 2026-04-08 19:02:17 +02:00
Aurel
327469411a typo 2026-04-08 10:29:46 +02:00
Aurel
31893074a6 [AI] Disable Enable Banking button while status is loading 2026-04-08 10:26:15 +02:00
Aurel
0cafb4acbc Fix code review findings on Enable Banking integration 2026-04-08 10:09:10 +02:00
Aurel Demiri
e8b6366816 Merge branch 'master' into feature/enable-banking 2026-04-08 10:01:03 +02:00
Aurel Demiri
e4b9d9c94e Merge branch 'master' into feature/enable-banking 2026-03-31 23:52:09 +02:00
Aurel
9403f57e6f Fix format
Expected "sign" (value-import) to come before "Algorithm"
2026-03-31 23:30:34 +02:00
Aurel
5661ab7a6f Add upcoming release notes 2026-03-31 23:26:55 +02:00
Aurel
8658b889ec Fix missing types for module jws 2026-03-31 23:26:34 +02:00
Aurel
cc544222da Respect ASPSP maximum_consent_validity when starting Enable Banking auth 2026-03-31 21:53:14 +02:00
Aurel
b13d04cc4a Fix Enable Banking re-auth dispatch 2026-03-31 21:53:13 +02:00
Aurel
dc62a6aff7 Forward PSU headers to Enable Banking API 2026-03-31 21:53:13 +02:00
Aurel
1cbe1efbf4 [AI] Fix missing patterns in Enable Banking integration
- Add SyncServerEnableBankingAccount to ExternalAccount union and
  getInstitutionName parameter type in SelectLinkedAccountsModal
- Use BankSyncProviders type in mobile BankSyncAccountsList instead of
  hardcoded union missing enableBanking
- Add getSecretsError handling to EnableBankingInitialiseModal for
  proper auth/permission error messages
- Replace hardcoded #666 color with theme.pageTextSubdued
- Wrap onConnectEnableBanking in try/catch with error notification and
  init modal re-open, matching SimpleFin/PluggyAI pattern
- Translate hardcoded error string in enablebanking.ts
- Add 60s timeout to downloadEnableBankingTransactions matching PluggyAI
- Revert out-of-scope changes to del()/patch() in post.ts
- Revert shared starting balance dedup logic back to master pattern
2026-03-31 21:53:13 +02:00
Aurel
33619dfc1d [AI] Address code review feedback for Enable Banking integration
Bug fixes:
- Fix double-negative for DBIT transaction amounts (e.g. '--25.99')
- Fix payeeName counterparty mapping (CRDT→debtor, DBIT→creditor)
- Add missing state validation in EnableBankingCallback and /auth_callback
- Fix stuck loading state in useEnableBankingStatus with try/catch/finally
- Make session-expiry error matching case-insensitive
- Prefer CLAV balance type for startingBalance in /transactions route
- Guard setTimeout in post/del/patch when timeout is null
- Distinguish abort from network failure in post() catch

Credential handling:
- Add validateCredentials() to validate before persisting secrets
- Refactor client to use enablebanking-configure instead of manual secret-set
- Distinguish null (loading) from false (not configured) in setup checks

Poll-auth robustness:
- Add unique waiter IDs to prevent superseded waiter cleanup race
- Always cache results in completedAuths for retry resilience
- Add client disconnect cleanup via res.on('close')
- Cancel poll when Enable Banking modal closes via AbortController
- Prevent concurrent poll controller race with local reference check

Code quality:
- Extract buildSessionResult() to deduplicate auth_callback/complete-auth
- Add enabled parameter to useEnableBankingStatus to skip unused requests
- Add re-entrancy guard on onJump, reset bank on country change
- Refetch bank list after Enable Banking setup completes
- Type enableBankingConfigure config, make state required in completeAuth
- Add AbortError→TIMED_OUT test, fix startAuth test assertion
- Add afterAll vi.unstubAllGlobals() for test cleanup
- Add explanatory comments for bank-per-account model and in-memory maps
2026-03-31 21:53:13 +02:00
Aurel
d8863a8d16 Integrate Enable Banking as bank sync provider
Rewrite Enable Banking modal to match GoCardless pattern

Resolve Enable Banking bugs and improve auth flow
2026-03-31 20:49:48 +02:00
43 changed files with 4214 additions and 14 deletions

View File

@@ -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",

View File

@@ -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;
};

View 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>
);
}

View File

@@ -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" />}

View File

@@ -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

View File

@@ -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],

View File

@@ -29,6 +29,7 @@ const useSyncSourceReadable = () => {
goCardless: 'GoCardless',
simpleFin: 'SimpleFIN',
pluggyai: 'Pluggy.ai',
enableBanking: 'Enable Banking',
unlinked: t('Unlinked'),
};

View File

@@ -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[]>;

View File

@@ -28,6 +28,7 @@ const useSyncSourceReadable = () => {
goCardless: 'GoCardless',
simpleFin: 'SimpleFIN',
pluggyai: 'Pluggy.ai',
enableBanking: 'Enable Banking',
unlinked: t('Unlinked'),
};

View File

@@ -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 ')}

View File

@@ -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> &rarr;
</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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 ?? '';

View File

@@ -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"

View 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',
},
},
}),
);
},
});
}

View 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,
};
}

View File

@@ -13,6 +13,7 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
customThemes: false,
budgetAnalysisReport: false,
payeeLocations: false,
enableBanking: false,
sankeyReport: false,
};

View File

@@ -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: {

View File

@@ -211,6 +211,7 @@ export default defineConfig(async ({ mode }) => {
/^\/plugins\/.*$/,
/^\/kcab\/.*$/,
/^\/plugin-data\/.*$/,
/^\/enablebanking\/.*$/,
],
},
}),

View File

@@ -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);

View File

@@ -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}`,

View File

@@ -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);

View File

@@ -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(

View File

@@ -21,4 +21,8 @@ export type AccountEntity = {
last_sync: string | null;
};
export type AccountSyncSource = 'simpleFin' | 'goCardless' | 'pluggyai';
export type AccountSyncSource =
| 'simpleFin'
| 'goCardless'
| 'pluggyai'
| 'enableBanking';

View File

@@ -20,4 +20,8 @@ export type BankSyncResponse = {
error_code: string;
};
export type BankSyncProviders = 'goCardless' | 'simpleFin' | 'pluggyai';
export type BankSyncProviders =
| 'goCardless'
| 'simpleFin'
| 'pluggyai'
| 'enableBanking';

View 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;
};

View File

@@ -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';

View File

@@ -9,6 +9,7 @@ export type FeatureFlag =
| 'customThemes'
| 'budgetAnalysisReport'
| 'payeeLocations'
| 'enableBanking'
| 'sankeyReport';
/**

View File

@@ -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",

View 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',
},
});
}
}),
);

View File

@@ -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;
},
};

View File

@@ -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',
}),
);
});
});
});

View File

@@ -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',
};

View File

@@ -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');
});
});

View File

@@ -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',
);
}
});
});
});

View 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);
}

View 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,
});
}

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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')) {

View File

@@ -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 {

View File

@@ -0,0 +1,6 @@
---
category: Features
authors: [AurelDemiri]
---
Integrate Enable Banking as a bank sync provider

View File

@@ -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"