Compare commits

...

33 Commits

Author SHA1 Message Date
github-actions[bot]
5b0a4d21b2 [AI] Merge master into feature/enable-banking
Reconcile with master's bank-sync provider refactor by wiring Enable
Banking into the new useBuiltInBankSyncProviders hook (gated by the
enableBanking feature flag), and add upgradingAccountId to the Enable
Banking flow so it matches the other providers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:06:19 +01:00
Aurel
8289865b66 [AI] Use SEPA prefix allowlist instead of catch-all regex
The previous `^[A-Z]{3,}\+` regex would incorrectly strip merchant
tokens like `BMW+`, `USB+`, or `COVID+` from the start of a remittance
line. Replaced it with an explicit allowlist of known SEPA / ISO 20022
prefixes and added a regression test covering the false-positive case.
2026-04-29 13:50:52 +02:00
Aurel
7762a07a57 [AI] Address Enable Banking CodeRabbit pass-3 follow-ups (round 2)
Two more findings from the latest CodeRabbit pass:

- Guard onJump against stale-retry completions. Token each call with a
  monotonic jumpIdRef counter and gate every post-await write
  (setError/setWaiting after onMoveExternal, the second setWaiting,
  and the finally-block ref reset) on `myJumpId === jumpIdRef.current`.
  Without this, a retry click while the previous poll was still
  unwinding could surface the older call's error in the newer
  attempt's UI and clear stateRef/isJumpingRef out from under it,
  leaving the new poll un-cancellable.
- Translate the (beta) suffix on Enable Banking ASPSP names so
  non-English locales don't surface a hardcoded English token in the
  bank list. The existing `actual/no-untranslated-strings` rule misses
  this case (regex requires a leading uppercase, and template-literal
  interpolations aren't visited as standalone strings).
2026-04-29 11:32:22 +02:00
Aurel
2ec3779b33 [AI] Address Enable Banking CodeRabbit pass-3 follow-ups
Three small fixes from the latest CodeRabbit re-review:

- Guard the aspsps fetch in EnableBankingExternalMsgModal against stale
  responses. Switching countries quickly could let an earlier in-flight
  request overwrite the newer selection's bank list. Added a cleanup
  flag in the useEffect so only the latest response updates state.
- Clear `enablebanking_auth_state` from localStorage when the auth flow
  exits, but only if the stored value still matches this attempt's
  state, so a concurrent retry can't wipe a newer session. Wrapping
  the poll in try/finally covers every return path (success, timeout,
  abort, body-level error).
- Use `Boolean(trans.booked)` in the Enable Banking initial-balance
  predicate to match `normalizeBankSyncTransactions`. The Enable
  Banking normalizer always sets `booked` to a boolean today, so this
  is defensive rather than a live bug, but keeping the two predicates
  aligned avoids surprises if the upstream shape ever loosens.
2026-04-29 10:55:23 +02:00
Aurel
3958933a26 [AI] Use req.ip for Enable Banking PSU header so trust-proxy whitelist applies 2026-04-29 09:11:08 +02:00
Aurel
89bea19dfd [AI] Improve Enable Banking bank-sync field mapping
Bring the Enable Banking transaction normalizer in line with how other
bank-sync providers feed the field mapper:

- Strip SEPA structured prefixes from remittance text so notes/payee
  display the human-meaningful portion instead of the SEPA boilerplate.
- Return the notes field and spread the raw transaction so downstream
  field mapping can reach the full payload.
- Expose Enable Banking raw fields in the bank-sync field mapper UI so
  users can map any underlying property, not just the curated subset.
2026-04-29 09:11:05 +02:00
Aurel
fd259a5b4c [AI] Refine Enable Banking error model and bank-sync surface
Carry the human-readable Enable Banking message in
EnableBankingError.error_type and the machine-friendly identifier in
error_code, then map error_code to a bank-sync category in the
/transactions wire format so AccountSyncCheck can match on the same
categories as other providers.
2026-04-29 09:10:49 +02:00
Aurel
4ac722e0c0 [AI] Fix Enable Banking initial-balance and post-link bookkeeping
Apply the standard post-sync bookkeeping when linking an Enable Banking
account so the new account picks up the same starting-balance
treatment as other bank-sync providers, and skip pending transactions
when computing the initial balance so the figure isn't inflated by
transactions that haven't cleared yet.
2026-04-29 09:10:49 +02:00
Aurel
f3272c74a6 [AI] Tighten Enable Banking client/test plumbing
Misc code-quality improvements with no behaviour change:

- Parallelize Enable Banking secret reset calls so wiping multiple
  secrets doesn't serialize the request chain.
- Use absolute imports in the enable-banking client module to match the
  rest of desktop-client.
- Document externalSignal usage in the post helper.
- Tighten Enable Banking test fixtures with `satisfies` and dynamic
  dates so they stop drifting when the real "now" moves.
2026-04-29 09:10:45 +02:00
Aurel
cbe15de31d [AI] Fix Enable Banking poll lifecycle and abort handling
Make the popup-driven auth poll cancellable and isolated:

- Allow the popup retry path to abort the in-flight poll instead of
  leaving it hanging on the previous attempt.
- Clear the Enable Banking stateRef when the retry attempt finishes so
  a new attempt starts from a clean state.
- Start useEnableBankingStatus in loading state until the first fetch
  resolves so the UI doesn't briefly flash "not connected".
- Cancel only the requested poll, not every in-flight Enable Banking
  poll, so unrelated link attempts aren't affected.
- Skip writing the poll response when the client has already
  disconnected, with a regression test covering the disconnect path.
2026-04-29 08:27:51 +02:00
Aurel
298c3b9773 [AI] Tighten Enable Banking type safety
Make the Enable Banking external-msg modal strict-ts compatible,
annotate the id type in linkEnableBankingAccount, derive
AccountSyncSource from a single SYNC_PROVIDERS list, and annotate the
return type of getJWTBody. No behaviour change.
2026-04-29 08:27:35 +02:00
Aurel
4042d32663 [AI] Harden Enable Banking OAuth callback handoff
Enforce exact OAuth state round-trip in the Enable Banking callback so
mismatched/missing state values no longer silently complete the flow.
Replace unsafe `as`/`!` assertions in the auth handoff with typed
locals so the callback path stays sound under strict TypeScript.
2026-04-29 08:27:22 +02:00
Aurel
b9f5121d18 [AI] Merge upstream/master into feature/enable-banking
Resolves conflicts in:
- packages/desktop-client/src/components/FinancesApp.tsx (kept both EnableBankingCallback and FeatureErrorFallback imports)
- packages/sync-server/package.json (kept @types/jws from branch + bumped @types/node from master)
- yarn.lock (re-resolved via yarn install)

Adapts to upstream #7529 (uuid removal): replace uuidv4() with
crypto.randomUUID() in:
- packages/loot-core/src/server/accounts/app.ts (linkEnableBankingAccount)
- packages/sync-server/src/app-enablebanking/app-enablebanking.ts (CSRF state)
2026-04-29 03:23:45 +02:00
Matiss Janis Aboltins
a90889b4de Add jws to dependencies 2026-04-12 20:57:39 +01:00
Matiss Janis Aboltins
14174e6c2f Merge branch 'master' into feature/enable-banking 2026-04-12 20:56:14 +01:00
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
42 changed files with 4447 additions and 32 deletions

View File

@@ -27,6 +27,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,118 @@
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 =
typeof stateParam === 'string' &&
typeof storedState === 'string' &&
stateParam === storedState;
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) {
localStorage.removeItem('enablebanking_auth_state');
setStatus('error');
setErrorMessage(t('Authorization state mismatch. Please try again.'));
return;
}
try {
const result = await send('enablebanking-complete-auth', {
code,
state: stateParam,
});
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, 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

@@ -24,6 +24,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 { FeatureErrorFallback } from './FeatureErrorFallback';
import { GlobalKeys } from './GlobalKeys';
import { MobileBankSyncAccountEditPage } from './mobile/banksync/MobileBankSyncAccountEditPage';
@@ -333,6 +334,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,12 @@ 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} />;
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

@@ -42,6 +42,9 @@ const mappableFields: MappableField[] = [
'valueDate',
'postedDate',
'transactedDate',
'booking_date',
'value_date',
'transaction_date',
],
},
{
@@ -64,6 +67,9 @@ const mappableFields: MappableField[] = [
'merchant.name',
'merchant.businessName',
'merchant.cnpj',
'creditor.name',
'debtor.name',
'account_servicer.name',
],
},
{
@@ -85,6 +91,8 @@ const mappableFields: MappableField[] = [
'merchant.name',
'merchant.businessName',
'merchant.cnpj',
'entry_reference',
'transaction_id',
],
},
];

View File

@@ -16,6 +16,7 @@ export const BUILT_IN_BANK_SYNC_PROVIDERS = [
const SYNC_PROVIDER_KEYS = [
...BUILT_IN_BANK_SYNC_PROVIDERS,
'enableBanking',
'unlinked',
] as const satisfies readonly SyncProviders[];
@@ -32,6 +33,7 @@ export function getSyncSourceReadable(
goCardless: 'GoCardless',
simpleFin: 'SimpleFIN',
pluggyai: 'Pluggy.ai',
enableBanking: 'Enable Banking',
unlinked: translate('Unlinked'),
};
}

View File

@@ -11,7 +11,10 @@ import type { SyncServerSimpleFinAccount } from '@actual-app/core/types/models/s
import { useAuth } from '#auth/AuthProvider';
import { Permissions } from '#auth/types';
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';
@@ -103,12 +106,17 @@ export function useBuiltInBankSyncProviders({
const [isPluggyAiSetupComplete, setIsPluggyAiSetupComplete] = useState<
boolean | null
>(null);
const [isEnableBankingSetupComplete, setIsEnableBankingSetupComplete] =
useState<boolean | null>(null);
const [loadingSimpleFinAccounts, setLoadingSimpleFinAccounts] =
useState(false);
const enableBankingEnabled = useFeatureFlag('enableBanking');
const { configuredGoCardless } = useGoCardlessStatus();
const { configuredSimpleFin } = useSimpleFinStatus();
const { configuredPluggyAi } = usePluggyAiStatus();
const { configuredEnableBanking, isLoading: isEnableBankingLoading } =
useEnableBankingStatus(enableBankingEnabled);
useEffect(() => {
setIsGoCardlessSetupComplete(configuredGoCardless);
@@ -122,6 +130,10 @@ export function useBuiltInBankSyncProviders({
setIsPluggyAiSetupComplete(configuredPluggyAi);
}, [configuredPluggyAi]);
useEffect(() => {
setIsEnableBankingSetupComplete(configuredEnableBanking);
}, [configuredEnableBanking]);
const onGoCardlessInit = useCallback(() => {
dispatch(
pushModal({
@@ -161,6 +173,19 @@ export function useBuiltInBankSyncProviders({
);
}, [dispatch]);
const onEnableBankingInit = useCallback(() => {
dispatch(
pushModal({
modal: {
name: 'enablebanking-init',
options: {
onSuccess: () => setIsEnableBankingSetupComplete(true),
},
},
}),
);
}, [dispatch]);
const notifyResetFailure = useCallback(
(providerName: string, error: unknown) => {
dispatch(
@@ -252,6 +277,28 @@ export function useBuiltInBankSyncProviders({
}
}, [notifyResetFailure]);
const onEnableBankingReset = useCallback(async () => {
try {
await ensureSuccessResponse(
await send('secret-set', {
name: 'enablebanking_applicationId',
value: null,
}),
'Failed to clear Enable Banking application ID',
);
await ensureSuccessResponse(
await send('secret-set', {
name: 'enablebanking_secretKey',
value: null,
}),
'Failed to clear Enable Banking secret key',
);
setIsEnableBankingSetupComplete(false);
} catch (error) {
notifyResetFailure('Enable Banking', error);
}
}, [notifyResetFailure]);
const onConnectGoCardless = useCallback(() => {
if (!isGoCardlessSetupComplete) {
onGoCardlessInit();
@@ -323,6 +370,35 @@ export function useBuiltInBankSyncProviders({
upgradingAccountId,
]);
const onConnectEnableBanking = useCallback(async () => {
if (!isEnableBankingSetupComplete) {
onEnableBankingInit();
return;
}
try {
await authorizeEnableBanking(dispatch, upgradingAccountId);
} catch (error) {
dispatch(
addNotification({
notification: {
type: 'error',
title: t('Error when trying to contact Enable Banking'),
message: error instanceof Error ? error.message : String(error),
timeout: 5000,
},
}),
);
onEnableBankingInit();
}
}, [
dispatch,
isEnableBankingSetupComplete,
onEnableBankingInit,
t,
upgradingAccountId,
]);
const onConnectPluggyAi = useCallback(async () => {
if (!isPluggyAiSetupComplete) {
onPluggyAiInit();
@@ -392,10 +468,11 @@ export function useBuiltInBankSyncProviders({
goCardless: Boolean(isGoCardlessSetupComplete),
simpleFin: Boolean(isSimpleFinSetupComplete),
pluggyai: Boolean(isPluggyAiSetupComplete),
enableBanking: Boolean(isEnableBankingSetupComplete),
} satisfies Record<BankSyncProviders, boolean>;
const providers = useMemo<BuiltInBankSyncProviderState[]>(
() =>
const providers = useMemo<BuiltInBankSyncProviderState[]>(() => {
const baseProviders: BuiltInBankSyncProviderState[] =
BUILT_IN_BANK_SYNC_PROVIDERS.map(providerId => {
if (providerId === 'goCardless') {
return {
@@ -440,25 +517,48 @@ export function useBuiltInBankSyncProviders({
onLink: onConnectPluggyAi,
onReset: onPluggyAiReset,
};
}),
[
canConfigureProviders,
configuredProviders.goCardless,
configuredProviders.pluggyai,
configuredProviders.simpleFin,
loadingSimpleFinAccounts,
onConnectGoCardless,
onConnectPluggyAi,
onConnectSimpleFin,
onGoCardlessInit,
onGoCardlessReset,
onPluggyAiInit,
onPluggyAiReset,
onSimpleFinInit,
onSimpleFinReset,
t,
],
);
});
if (enableBankingEnabled) {
baseProviders.push({
id: 'enableBanking',
displayName: 'Enable Banking',
description: t(
'Link a European bank account via Enable Banking, a free alternative to GoCardless for PSD2-supported banks.',
),
isConfigured: configuredProviders.enableBanking,
canConfigure: canConfigureProviders,
isLoading: isEnableBankingLoading,
onConfigure: onEnableBankingInit,
onLink: onConnectEnableBanking,
onReset: onEnableBankingReset,
});
}
return baseProviders;
}, [
canConfigureProviders,
configuredProviders.enableBanking,
configuredProviders.goCardless,
configuredProviders.pluggyai,
configuredProviders.simpleFin,
enableBankingEnabled,
isEnableBankingLoading,
loadingSimpleFinAccounts,
onConnectEnableBanking,
onConnectGoCardless,
onConnectPluggyAi,
onConnectSimpleFin,
onEnableBankingInit,
onEnableBankingReset,
onGoCardlessInit,
onGoCardlessReset,
onPluggyAiInit,
onPluggyAiReset,
onSimpleFinInit,
onSimpleFinReset,
t,
]);
const providersNeedingConfiguration = providers.filter(
provider => !provider.isConfigured,

View File

@@ -0,0 +1,426 @@
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 | undefined,
refetchKey?: boolean | null,
) {
const { t } = useTranslation();
const [banks, setBanks] = useState<BankOption[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
useEffect(() => {
let cancelled = false;
async function fetch() {
setIsError(false);
if (!country) {
if (!cancelled) {
setBanks([]);
setIsLoading(false);
}
return;
}
setIsLoading(true);
const { data, error } = await sendCatch(
'enablebanking-aspsps',
country.toUpperCase(),
);
if (cancelled) return;
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} ${t('(beta)')}` : aspsp.name,
maxConsentValidity: aspsp.maximum_consent_validity,
})),
);
}
setIsLoading(false);
}
void fetch();
return () => {
cancelled = true;
};
}, [country, refetchKey, t]);
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);
const stateRef = useRef<string | null>(null);
// Each onJump call captures a token from this counter. A retry that
// supersedes an in-flight call increments the counter, so the older call
// can detect it has been superseded and skip its post-await writes
// instead of clobbering the newer attempt's UI state and refs.
const jumpIdRef = useRef(0);
async function handleClose() {
if (stateRef.current !== null) {
await sendCatch('enablebanking-poll-auth-stop', {
state: stateRef.current,
});
}
onClose?.();
}
async function onJump() {
const myJumpId = ++jumpIdRef.current;
if (isJumpingRef.current) {
// Abort the in-flight poll so we can re-open the popup immediately.
// Only send the stop RPC if we have a state to target; if onMoveExternal
// hasn't set stateRef yet there is no active poll to abort.
if (stateRef.current !== null) {
await sendCatch('enablebanking-poll-auth-stop', {
state: stateRef.current,
});
}
isJumpingRef.current = false;
}
isJumpingRef.current = true;
try {
setError(null);
setWaiting('browser');
if (!selectedAspsp) return;
// 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,
onStateReady: state => {
if (myJumpId === jumpIdRef.current) {
stateRef.current = state;
}
},
});
// A retry has superseded this call — drop the result so it can't
// overwrite the newer attempt's error or waiting state.
if (myJumpId !== jumpIdRef.current) return;
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);
if (myJumpId !== jumpIdRef.current) return;
setWaiting(null);
} finally {
if (myJumpId === jumpIdRef.current) {
isJumpingRef.current = false;
stateRef.current = null;
}
}
}
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={handleClose}
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,
@@ -87,6 +89,12 @@ export type SelectLinkedAccountsModalProps =
externalAccounts: SyncServerPluggyAiAccount[];
syncSource: 'pluggyai';
upgradingAccountId?: string;
}
| {
requisitionId?: undefined;
externalAccounts: SyncServerEnableBankingAccount[];
syncSource: 'enableBanking';
upgradingAccountId?: string;
};
export function SelectLinkedAccountsModal({
@@ -123,6 +131,12 @@ export function SelectLinkedAccountsModal({
externalAccounts: toSort as SyncServerGoCardlessAccount[],
upgradingAccountId,
};
case 'enableBanking':
return {
syncSource: 'enableBanking',
externalAccounts: toSort as SyncServerEnableBankingAccount[],
upgradingAccountId,
};
default:
throw new Error(`Unrecognized sync source: ${String(syncSource)}`);
}
@@ -180,6 +194,7 @@ export function SelectLinkedAccountsModal({
const unlinkAccount = useUnlinkAccountMutation();
const linkAccountSimpleFin = useLinkAccountSimpleFinMutation();
const linkAccountPluggyAi = useLinkAccountPluggyAiMutation();
const linkAccountEnableBanking = useLinkAccountEnableBankingMutation();
async function onNext() {
const chosenLocalAccountIds = Object.values(chosenAccounts);
@@ -245,6 +260,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,
@@ -500,7 +532,8 @@ export function SelectLinkedAccountsModal({
type ExternalAccount =
| SyncServerGoCardlessAccount
| SyncServerSimpleFinAccount
| SyncServerPluggyAiAccount;
| SyncServerPluggyAiAccount
| SyncServerEnableBankingAccount;
type StartingBalanceInfo = {
date: string;
@@ -738,7 +771,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,147 @@
import { sendCatch } from '@actual-app/core/platform/client/connection';
import type {
AccountEntity,
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,
onStateReady,
}) => {
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);
onStateReady?.(state);
window.open(
authUrl,
'enablebanking-auth',
'width=600,height=700,popup=yes',
);
try {
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 } };
} finally {
// Only clear if this attempt's state is still the one stored;
// a concurrent retry may have overwritten it with a newer one.
if (localStorage.getItem('enablebanking_auth_state') === state) {
localStorage.removeItem('enablebanking_auth_state');
}
}
},
onClose,
onSuccess,
},
},
}),
);
}
export async function authorizeBank(
dispatch: AppDispatch,
upgradingAccountId?: AccountEntity['id'],
) {
_authorize(dispatch, {
onSuccess: async data => {
dispatch(
pushModal({
modal: {
name: 'select-linked-accounts',
options: {
externalAccounts: data.accounts,
syncSource: 'enableBanking',
upgradingAccountId,
},
},
}),
);
},
});
}

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(true);
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,
@@ -127,6 +128,31 @@ export type Modal =
onSuccess: () => void;
};
}
| {
name: 'enablebanking-init';
options: {
onSuccess: () => void;
};
}
| {
name: 'enablebanking-external-msg';
options: {
onMoveExternal: (arg: {
aspspId: string;
country: string;
maxConsentValidity?: number;
onStateReady?: (state: string) => void;
}) => 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

@@ -394,6 +394,7 @@ export default defineConfig(async ({ mode, command }) => {
/^\/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,88 @@ async function linkPluggyAiAccount({
return 'ok';
}
async function linkEnableBankingAccount({
externalAccount,
upgradingId,
offBudget = false,
startingDate,
startingBalance,
}: LinkAccountBaseParams & {
externalAccount: SyncServerEnableBankingAccount;
}) {
let id: string | undefined;
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 = crypto.randomUUID();
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,
});
}
if (id == null) {
throw new Error('id was not assigned in linkEnableBankingAccount');
}
const syncRes = await bankSync.syncAccount(
undefined,
undefined,
id,
externalAccount.account_id,
bank.bank_id,
startingDate,
startingBalance,
);
await handleSyncResponse(syncRes, id);
connection.send('sync-event', {
type: 'success',
tables: ['transactions'],
});
return 'ok';
}
async function createAccount({
name,
balance = 0,
@@ -784,6 +875,183 @@ 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({ state }: { state: string }) {
const controller = enableBankingPollControllers.get(state);
if (controller) {
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 +1551,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 +1563,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,19 @@ async function processBankSyncDownload(
currentBalance,
);
balanceToUse = Math.round(previousBalance);
} else if (acctRow.account_sync_source === 'enableBanking') {
const importPending = await aqlQuery(
q('preferences')
.filter({ id: `sync-import-pending-${id}` })
.select('value'),
).then(data => String(data?.data?.[0]?.value ?? 'true') === 'true');
const importable = importPending
? transactions
: transactions.filter(trans => Boolean(trans.booked));
const previousBalance = importable.reduce((total, trans) => {
return total - amountToInteger(trans.transactionAmount.amount);
}, currentBalance);
balanceToUse = previousBalance;
}
const oldestTransaction = transactions[transactions.length - 1];
@@ -1076,6 +1127,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,29 @@ export async function post(
data: unknown,
headers = {},
timeout: number | null = null,
// Optional caller-provided abort signal. Used by Enable Banking poll
// cancellation so the user can interrupt the 5-minute long-poll.
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 +70,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

@@ -1,3 +1,5 @@
import type { BankSyncProviders } from './bank-sync';
export type AccountEntity = {
id: string;
name: string;
@@ -21,4 +23,4 @@ export type AccountEntity = {
last_sync: string | null;
};
export type AccountSyncSource = 'simpleFin' | 'goCardless' | 'pluggyai';
export type AccountSyncSource = BankSyncProviders;

View File

@@ -20,4 +20,11 @@ export type BankSyncResponse = {
error_code: string;
};
export type BankSyncProviders = 'goCardless' | 'simpleFin' | 'pluggyai';
export const SYNC_PROVIDERS = [
'goCardless',
'simpleFin',
'pluggyai',
'enableBanking',
] as const;
export type BankSyncProviders = (typeof SYNC_PROVIDERS)[number];

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

@@ -23,6 +23,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",
@@ -48,6 +51,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",
@@ -96,6 +102,7 @@
"express-rate-limit": "^8.3.2",
"express-winston": "^4.2.0",
"ipaddr.js": "^2.3.0",
"jws": "^3.2.2",
"migrate": "^2.1.0",
"openid-client": "^5.7.1",
"pluggy-sdk": "^0.83.0",
@@ -109,6 +116,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.17",
"@types/supertest": "^7.2.0",
"@typescript/native-preview": "beta",

View File

@@ -0,0 +1,591 @@
import createDebug from 'debug';
import type { Request, Response } from 'express';
import express from 'express';
import { handleError } from '#app-gocardless/util/handle-error';
import { SecretName, secretsService } from '#services/secrets-service';
import {
requestLoggerMiddleware,
validateSessionMiddleware,
} from '#util/middlewares';
import type {
EnableBankingSession,
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 = 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: EnableBankingSession,
psuHeaders?: PsuHeaders,
) {
const accountsWithBalances = await Promise.all(
session.accounts.map(async account => {
const normalized = normalizeAccount(account, 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);
const pending = pendingAuths.get(state);
if (pending) {
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);
const pending = pendingAuths.get(state);
if (pending) {
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 = crypto.randomUUID();
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);
const pending = pendingAuths.get(state);
if (pending) {
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);
const pending = pendingAuths.get(state);
if (pending) {
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);
let hasClientDisconnected = false;
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
const existing = pendingAuths.get(state);
if (existing) {
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) {
hasClientDisconnected = true;
cleanupPendingAuth(state, waiterId);
safeReject(new Error('Client disconnected'));
}
});
});
if (hasClientDisconnected || res.destroyed || res.writableEnded) {
return;
}
res.send({
status: 'ok',
data: result,
});
} catch (error) {
cleanupPendingAuth(state, waiterId);
if (hasClientDisconnected || res.destroyed || res.writableEnded) {
return;
}
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;
}
// The bank-sync wire format expects `error_type` to be a broad
// machine-readable category (matched by AccountSyncCheck's switch),
// not the human message we now keep on `EnableBankingError.error_type`.
const wireErrorType =
error.error_code === 'NOT_FOUND' ? 'INVALID_INPUT' : error.error_code;
res.send({
status: 'ok',
data: {
error_type: wireErrorType,
error_code: error.error_code,
},
});
return;
}
res.send({
status: 'ok',
data: {
error_type: 'INTERNAL_ERROR',
error_code: 'INTERNAL_ERROR',
},
});
}
}),
);

View File

@@ -0,0 +1,431 @@
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 ---
export 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;
};
export type EnableBankingSessionAccount = {
account_id?: { iban?: string };
account_servicer?: { bic_fi?: string; name?: string };
name?: string;
currency?: string;
uid: string;
};
export 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 = EnableBankingTransaction & {
transactionId: string;
date: string;
bookingDate: string;
valueDate?: string;
transactionAmount: { amount: string; currency: string };
payeeName: string;
notes?: 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 ---
// SEPA / ISO 20022 structured remittance prefixes (e.g. `EREF+invoice-42`).
// They are metadata for clearing systems, not user-facing text, so we strip
// them from the front of each remittance line. The list is an allowlist of
// known prefixes rather than a catch-all `[A-Z]{3,}\+` so we don't accidentally
// strip merchant tokens like `BMW+` or `USB+` that legitimately start a
// description.
const SEPA_PREFIX_RE =
/^(?:EREF|KREF|MREF|CRED|DBTR|CDTR|SVWZ|SVCL|PURP|RTRN|REJT|REFE|SDVA|INDA|NTAV|ULTC|ULTD|ULTB|ABWA|ABWE|IBAN|BIC|COAM|OAMT|REMI|SQTP|ROC)\+/;
function stripSepaPrefix(s: string): string {
return s.replace(SEPA_PREFIX_RE, '').trim();
}
function cleanRemittanceArray(arr: string[]): string[] {
return arr.map(stripSepaPrefix).filter(Boolean);
}
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
) {
const cleanedFallback = cleanRemittanceArray(tx.remittance_information);
if (cleanedFallback.length > 0) {
payeeName = cleanedFallback[0];
}
}
const cleanedAll = tx.remittance_information
? cleanRemittanceArray(tx.remittance_information)
: [];
const remittanceInformationUnstructured =
cleanedAll.length > 0 ? cleanedAll.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 {
...tx,
transactionId,
date: bookingDate,
bookingDate,
valueDate,
transactionAmount: {
amount: signedAmount,
currency: tx.transaction_amount.currency,
},
payeeName,
notes: remittanceInformationUnstructured,
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,120 @@
import type { EnableBankingTransaction } from '#app-enablebanking/services/enablebanking-service';
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' },
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: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
},
};
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',
status: 'BOOK',
booking_date: '2026-03-01',
value_date: '2026-03-01',
remittance_information: ['Monthly salary', 'March 2026'],
} satisfies EnableBankingTransaction;
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',
status: 'BOOK',
booking_date: '2026-03-02',
value_date: '2026-03-02',
remittance_information: ['Groceries purchase'],
} satisfies EnableBankingTransaction;
export const mockPendingTransaction = {
transaction_id: 'tx-003',
transaction_amount: { currency: 'EUR', amount: '-10.00' },
status: 'PDNG',
value_date: '2026-03-03',
remittance_information: ['Card payment'],
} satisfies EnableBankingTransaction;
export const mockTransactionNoPayee = {
entry_reference: 'ref-004',
transaction_amount: { currency: 'EUR', amount: '5.00' },
status: 'BOOK',
booking_date: '2026-03-04',
remittance_information: ['Transfer from savings'],
} satisfies EnableBankingTransaction;
export const mockTransactionMinimal = {
transaction_amount: { currency: 'EUR', amount: '1.23' },
status: 'BOOK',
} satisfies EnableBankingTransaction;
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,248 @@
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('SEPA prefix stripping', () => {
it('strips EREF+ from a single line', () => {
const tx = {
transaction_id: 'tx-prefix-1',
transaction_amount: { currency: 'EUR', amount: '10.00' },
credit_debit_indicator: 'CRDT' as const,
status: 'BOOK' as const,
booking_date: '2026-04-01',
remittance_information: ['EREF+invoice-42', 'thanks'],
};
const out = normalizeTransaction(tx);
expect(out.payeeName).toBe('invoice-42');
expect(out.remittanceInformationUnstructured).toBe('invoice-42 thanks');
});
it('drops empty entries after stripping', () => {
const tx = {
transaction_id: 'tx-prefix-2',
transaction_amount: { currency: 'EUR', amount: '5.00' },
credit_debit_indicator: 'CRDT' as const,
status: 'BOOK' as const,
booking_date: '2026-04-02',
remittance_information: ['EREF+', 'paid'],
};
const out = normalizeTransaction(tx);
expect(out.remittanceInformationUnstructured).toBe('paid');
});
it('returns undefined when stripping leaves nothing', () => {
const tx = {
transaction_id: 'tx-prefix-3',
transaction_amount: { currency: 'EUR', amount: '5.00' },
credit_debit_indicator: 'CRDT' as const,
status: 'BOOK' as const,
booking_date: '2026-04-02',
remittance_information: ['EREF+'],
};
const out = normalizeTransaction(tx);
expect(out.remittanceInformationUnstructured).toBeUndefined();
});
it('preserves merchant tokens that look like prefixes but are not on the allowlist', () => {
const tx = {
transaction_id: 'tx-prefix-4',
transaction_amount: { currency: 'EUR', amount: '99.00' },
credit_debit_indicator: 'DBIT' as const,
status: 'BOOK' as const,
booking_date: '2026-04-04',
remittance_information: [
'BMW+ Service Vertrag',
'USB+HDMI Kabel',
'COVID+ Test Apotheke',
],
};
const out = normalizeTransaction(tx);
expect(out.remittanceInformationUnstructured).toBe(
'BMW+ Service Vertrag USB+HDMI Kabel COVID+ Test Apotheke',
);
expect(out.payeeName).toBe('BMW+ Service Vertrag');
});
});
describe('normalizeTransaction shape for bank-sync mapping', () => {
it('exposes notes equal to remittanceInformationUnstructured', () => {
const tx = {
transaction_id: 'tx-notes-1',
transaction_amount: { currency: 'EUR', amount: '12.34' },
credit_debit_indicator: 'CRDT' as const,
status: 'BOOK' as const,
booking_date: '2026-04-03',
remittance_information: ['hello world'],
};
const out = normalizeTransaction(tx);
expect(out.notes).toBe('hello world');
expect(out.notes).toBe(out.remittanceInformationUnstructured);
});
it('spreads the raw fields onto the normalized object', () => {
const tx = {
entry_reference: 'ref-raw-1',
transaction_id: 'tx-raw-1',
transaction_amount: { currency: 'EUR', amount: '12.34' },
creditor: { name: 'Acme' },
credit_debit_indicator: 'DBIT' as const,
status: 'BOOK' as const,
booking_date: '2026-04-03',
};
const out = normalizeTransaction(tx);
expect(out.entry_reference).toBe('ref-raw-1');
expect(out.creditor).toEqual({ name: 'Acme' });
expect(out.credit_debit_indicator).toBe('DBIT');
});
});
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,548 @@
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();
// Mirror the production sync-server trust-proxy setup so req.ip honors
// X-Forwarded-For from trusted upstreams.
app.set('trust proxy', true);
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');
});
it('does not write to the response after the client disconnects', async () => {
// The /poll-auth handler attaches `res.on('close', ...)` to clean up
// the pending waiter and reject its internal promise. After that, the
// handler must not call res.send() — that would log a "write after end"
// warning and (in stricter Node setups) throw.
const noop = () => undefined;
const errorSpy = vi.spyOn(console, 'error').mockImplementation(noop);
const state = 'disconnect-test-state';
const req = request(app).post('/poll-auth').send({ state });
// Abort once the handler has had time to register the close listener.
setTimeout(() => req.abort(), 50);
await req.catch(() => {
// supertest rejects on aborted requests; that's expected.
});
// Give the server a tick to process 'close' and unwind the promise.
await new Promise(resolve => setTimeout(resolve, 100));
const writeAfterEndCalls = errorSpy.mock.calls.filter(args =>
args.some(
arg =>
(typeof arg === 'string' && arg.includes('write after')) ||
(arg instanceof Error && arg.message.includes('write after')),
),
);
expect(writeAfterEndCalls).toHaveLength(0);
errorSpy.mockRestore();
});
});
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' });
// error_type carries the bank-sync category (matched by AccountSyncCheck).
expect(res.body.data.error_type).toBe('RATE_LIMIT_EXCEEDED');
expect(res.body.data.error_code).toBe('RATE_LIMIT_EXCEEDED');
});
it('maps 404 to INVALID_INPUT category in error_type', async () => {
mockFetchResponse({ message: 'Account not found' }, false, 404);
const res = await request(app)
.post('/transactions')
.send({ accountId: 'uid-1', startDate: '2026-01-01' });
expect(res.body.data.error_type).toBe('INVALID_INPUT');
expect(res.body.data.error_code).toBe('NOT_FOUND');
});
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,60 @@
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(message, 'INVALID_ACCESS_TOKEN', message);
}
if (statusCode === 429) {
return new EnableBankingError(message, 'RATE_LIMIT_EXCEEDED', message);
}
if (statusCode === 404) {
return new EnableBankingError(message, '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(message, 'INVALID_ACCESS_TOKEN', message);
}
return new EnableBankingError(message, 'INVALID_INPUT', message);
}
return new EnableBankingError(message, 'INTERNAL_ERROR', message);
}

View File

@@ -0,0 +1,37 @@
import { sign } from 'jws';
import type { Algorithm } from 'jws';
type Header = { typ: string; alg: Algorithm; kid: string };
type JWTPayload = {
iss: string;
aud: string;
iat: number;
exp: number;
};
function getJWTHeader(applicationId: string): Header {
return { typ: 'JWT', alg: 'RS256', kid: applicationId };
}
function getJWTBody(exp = 3600): JWTPayload {
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,91 @@
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('Unauthorized');
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_type).toBe('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('Too many requests');
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_type).toBe('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_type).toBe('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_type).toBe('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('Server 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');
expect(error.error_type).toBe('raw error text');
expect(error.error_code).toBe('INTERNAL_ERROR');
});
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

@@ -186,6 +186,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.17"
"@types/supertest": "npm:^7.2.0"
"@typescript/native-preview": "npm:beta"
@@ -200,6 +201,7 @@ __metadata:
express-winston: "npm:^4.2.0"
http-proxy-middleware: "npm:^3.0.5"
ipaddr.js: "npm:^2.3.0"
jws: "npm:^3.2.2"
migrate: "npm:^2.1.0"
nodemon: "npm:^3.1.14"
openid-client: "npm:^5.7.1"
@@ -9840,6 +9842,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"