mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-11 09:38:37 -05:00
Compare commits
9 Commits
feature/en
...
claude/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
320d66444a | ||
|
|
17198863a4 | ||
|
|
a9f8ae0e21 | ||
|
|
3799b587ec | ||
|
|
8e1f27f316 | ||
|
|
fb95d4c92d | ||
|
|
2782d464ab | ||
|
|
b63f5dd303 | ||
|
|
35a01b0fa6 |
@@ -1,7 +1,7 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose
|
||||
{
|
||||
"name": "Actual development",
|
||||
"name": "Actual Devcontainer",
|
||||
"dockerComposeFile": ["../docker-compose.yml", "docker-compose.yml"],
|
||||
// Alternatively:
|
||||
// "image": "mcr.microsoft.com/devcontainers/typescript-node:0-16",
|
||||
|
||||
1
.github/actions/docs-spelling/expect.txt
vendored
1
.github/actions/docs-spelling/expect.txt
vendored
@@ -44,6 +44,7 @@ CLP
|
||||
CMCIFRPAXXX
|
||||
COBADEFF
|
||||
CODEOWNERS
|
||||
Codespaces
|
||||
COEP
|
||||
commerzbank
|
||||
Copiar
|
||||
|
||||
@@ -516,6 +516,29 @@ describe('API CRUD operations', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// apis: getNote, updateNote
|
||||
test('Notes: successfully get and update note', async () => {
|
||||
const categories = await api.getCategories();
|
||||
const categoryId = categories[0].id;
|
||||
|
||||
// No note exists initially
|
||||
const initial = await api.getNote(categoryId);
|
||||
expect(initial).toBeNull();
|
||||
|
||||
// Set a note
|
||||
await api.updateNote(categoryId, 'Test note content');
|
||||
const afterSet = await api.getNote(categoryId);
|
||||
expect(afterSet).toEqual({ id: categoryId, note: 'Test note content' });
|
||||
|
||||
// Update the note
|
||||
await api.updateNote(categoryId, 'Updated note content');
|
||||
const afterUpdate = await api.getNote(categoryId);
|
||||
expect(afterUpdate).toEqual({
|
||||
id: categoryId,
|
||||
note: 'Updated note content',
|
||||
});
|
||||
});
|
||||
|
||||
// apis: getRules, getPayeeRules, createRule, updateRule, deleteRule
|
||||
test('Rules: successfully update rules', async () => {
|
||||
await api.createPayee({ name: 'test-payee' });
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { ImportTransactionsOpts } from '@actual-app/core/types/api-handlers
|
||||
import type { Handlers } from '@actual-app/core/types/handlers';
|
||||
import type {
|
||||
ImportTransactionEntity,
|
||||
NoteEntity,
|
||||
RuleEntity,
|
||||
TransactionEntity,
|
||||
} from '@actual-app/core/types/models';
|
||||
@@ -247,6 +248,14 @@ export function deleteCategory(
|
||||
return send('api/category-delete', { id, transferCategoryId });
|
||||
}
|
||||
|
||||
export function getNote(id: NoteEntity['id']) {
|
||||
return send('api/note-get', { id });
|
||||
}
|
||||
|
||||
export function updateNote(id: NoteEntity['id'], note: NoteEntity['note']) {
|
||||
return send('api/note-update', { id, note });
|
||||
}
|
||||
|
||||
export function getCommonPayees() {
|
||||
return send('api/common-payees-get');
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
"#transactions": "./src/transactions/index.ts",
|
||||
"#undo": "./src/undo/index.ts",
|
||||
"#global-events": "./src/global-events.ts",
|
||||
"#enablebanking": "./src/enablebanking.ts",
|
||||
"#gocardless": "./src/gocardless.ts",
|
||||
"#i18n": "./src/i18n.ts",
|
||||
"#mocks": "./src/mocks.tsx",
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { SyncResponseWithErrors } from '@actual-app/core/server/accounts/ap
|
||||
import type {
|
||||
AccountEntity,
|
||||
CategoryEntity,
|
||||
SyncServerEnableBankingAccount,
|
||||
SyncServerGoCardlessAccount,
|
||||
SyncServerPluggyAiAccount,
|
||||
SyncServerSimpleFinAccount,
|
||||
@@ -500,48 +499,6 @@ 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;
|
||||
};
|
||||
@@ -633,6 +590,8 @@ export function useSyncAccountsMutation() {
|
||||
accountIdsToSync = accountIdsToSync.filter(
|
||||
id => !simpleFinAccounts.find(sfa => sfa.id === id),
|
||||
);
|
||||
|
||||
dispatch(setAccountsSyncing({ ids: accountIdsToSync }));
|
||||
}
|
||||
|
||||
// Loop through the accounts and perform sync operation.. one by one
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,6 @@ 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';
|
||||
@@ -334,11 +333,6 @@ export function FinancesApp() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/enablebanking/auth_callback"
|
||||
element={<EnableBankingCallback />}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/accounts"
|
||||
element={<NarrowAlternate name="Accounts" />}
|
||||
|
||||
@@ -36,8 +36,6 @@ 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';
|
||||
@@ -189,12 +187,6 @@ 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
|
||||
|
||||
@@ -11,8 +11,7 @@ import type { AccountEntity } from '@actual-app/core/types/models';
|
||||
|
||||
import { useUnlinkAccountMutation } from '#accounts';
|
||||
import { Link } from '#components/common/Link';
|
||||
import { authorizeBank as authorizeEnableBanking } from '#enablebanking';
|
||||
import { authorizeBank as authorizeGoCardless } from '#gocardless';
|
||||
import { authorizeBank } from '#gocardless';
|
||||
import { useAccounts } from '#hooks/useAccounts';
|
||||
import { useFailedAccounts } from '#hooks/useFailedAccounts';
|
||||
import { useDispatch } from '#redux';
|
||||
@@ -104,11 +103,7 @@ export function AccountSyncCheck() {
|
||||
setOpen(false);
|
||||
|
||||
if (acc.account_id) {
|
||||
if (acc.account_sync_source === 'enableBanking') {
|
||||
void authorizeEnableBanking(dispatch);
|
||||
} else if (acc.account_sync_source === 'goCardless') {
|
||||
void authorizeGoCardless(dispatch);
|
||||
}
|
||||
void authorizeBank(dispatch);
|
||||
}
|
||||
},
|
||||
[dispatch],
|
||||
|
||||
@@ -42,9 +42,6 @@ const mappableFields: MappableField[] = [
|
||||
'valueDate',
|
||||
'postedDate',
|
||||
'transactedDate',
|
||||
'booking_date',
|
||||
'value_date',
|
||||
'transaction_date',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -67,9 +64,6 @@ const mappableFields: MappableField[] = [
|
||||
'merchant.name',
|
||||
'merchant.businessName',
|
||||
'merchant.cnpj',
|
||||
'creditor.name',
|
||||
'debtor.name',
|
||||
'account_servicer.name',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -91,8 +85,6 @@ const mappableFields: MappableField[] = [
|
||||
'merchant.name',
|
||||
'merchant.businessName',
|
||||
'merchant.cnpj',
|
||||
'entry_reference',
|
||||
'transaction_id',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -16,7 +16,6 @@ export const BUILT_IN_BANK_SYNC_PROVIDERS = [
|
||||
|
||||
const SYNC_PROVIDER_KEYS = [
|
||||
...BUILT_IN_BANK_SYNC_PROVIDERS,
|
||||
'enableBanking',
|
||||
'unlinked',
|
||||
] as const satisfies readonly SyncProviders[];
|
||||
|
||||
@@ -33,7 +32,6 @@ export function getSyncSourceReadable(
|
||||
goCardless: 'GoCardless',
|
||||
simpleFin: 'SimpleFIN',
|
||||
pluggyai: 'Pluggy.ai',
|
||||
enableBanking: 'Enable Banking',
|
||||
unlinked: translate('Unlinked'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,10 +11,7 @@ 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';
|
||||
@@ -106,17 +103,12 @@ 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);
|
||||
@@ -130,10 +122,6 @@ export function useBuiltInBankSyncProviders({
|
||||
setIsPluggyAiSetupComplete(configuredPluggyAi);
|
||||
}, [configuredPluggyAi]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsEnableBankingSetupComplete(configuredEnableBanking);
|
||||
}, [configuredEnableBanking]);
|
||||
|
||||
const onGoCardlessInit = useCallback(() => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
@@ -173,19 +161,6 @@ 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(
|
||||
@@ -277,28 +252,6 @@ 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();
|
||||
@@ -370,35 +323,6 @@ 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();
|
||||
@@ -468,11 +392,10 @@ export function useBuiltInBankSyncProviders({
|
||||
goCardless: Boolean(isGoCardlessSetupComplete),
|
||||
simpleFin: Boolean(isSimpleFinSetupComplete),
|
||||
pluggyai: Boolean(isPluggyAiSetupComplete),
|
||||
enableBanking: Boolean(isEnableBankingSetupComplete),
|
||||
} satisfies Record<BankSyncProviders, boolean>;
|
||||
|
||||
const providers = useMemo<BuiltInBankSyncProviderState[]>(() => {
|
||||
const baseProviders: BuiltInBankSyncProviderState[] =
|
||||
const providers = useMemo<BuiltInBankSyncProviderState[]>(
|
||||
() =>
|
||||
BUILT_IN_BANK_SYNC_PROVIDERS.map(providerId => {
|
||||
if (providerId === 'goCardless') {
|
||||
return {
|
||||
@@ -517,48 +440,25 @@ export function useBuiltInBankSyncProviders({
|
||||
onLink: onConnectPluggyAi,
|
||||
onReset: onPluggyAiReset,
|
||||
};
|
||||
});
|
||||
|
||||
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,
|
||||
]);
|
||||
}),
|
||||
[
|
||||
canConfigureProviders,
|
||||
configuredProviders.goCardless,
|
||||
configuredProviders.pluggyai,
|
||||
configuredProviders.simpleFin,
|
||||
loadingSimpleFinAccounts,
|
||||
onConnectGoCardless,
|
||||
onConnectPluggyAi,
|
||||
onConnectSimpleFin,
|
||||
onGoCardlessInit,
|
||||
onGoCardlessReset,
|
||||
onPluggyAiInit,
|
||||
onPluggyAiReset,
|
||||
onSimpleFinInit,
|
||||
onSimpleFinReset,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const providersNeedingConfiguration = providers.filter(
|
||||
provider => !provider.isConfigured,
|
||||
|
||||
@@ -81,7 +81,6 @@ export const Modal = ({
|
||||
inset: 0,
|
||||
zIndex: MODAL_Z_INDEX,
|
||||
fontSize: 14,
|
||||
willChange: 'transform',
|
||||
// on mobile, we disable the blurred background for performance reasons
|
||||
...(isNarrowWidth
|
||||
? {
|
||||
|
||||
@@ -466,6 +466,7 @@ const AccountList = forwardRef<HTMLDivElement, AccountListProps>(
|
||||
<ListBox
|
||||
aria-label={ariaLabel}
|
||||
items={accounts}
|
||||
dependencies={[syncingAccountIds, failedAccounts, updatedAccounts]}
|
||||
dragAndDropHooks={dragAndDropHooks}
|
||||
ref={ref}
|
||||
style={{
|
||||
|
||||
@@ -1,426 +0,0 @@
|
||||
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> →
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { ButtonWithLoading } from '@actual-app/components/button';
|
||||
import { SvgCheckCircle1 } from '@actual-app/components/icons/v2';
|
||||
import { InitialFocus } from '@actual-app/components/initial-focus';
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { send } from '@actual-app/core/platform/client/connection';
|
||||
import { getSecretsError } from '@actual-app/core/shared/errors';
|
||||
|
||||
import { Error as ErrorAlert } from '#components/alerts';
|
||||
import { Link } from '#components/common/Link';
|
||||
import {
|
||||
Modal,
|
||||
ModalButtons,
|
||||
ModalCloseButton,
|
||||
ModalHeader,
|
||||
} from '#components/common/Modal';
|
||||
import { FormField, FormLabel } from '#components/forms';
|
||||
import type { Modal as ModalType } from '#modals/modalsSlice';
|
||||
|
||||
type EnableBankingInitialiseProps = Extract<
|
||||
ModalType,
|
||||
{ name: 'enablebanking-init' }
|
||||
>['options'];
|
||||
|
||||
export function EnableBankingInitialiseModal({
|
||||
onSuccess,
|
||||
}: EnableBankingInitialiseProps) {
|
||||
const { t } = useTranslation();
|
||||
const [applicationId, setApplicationId] = useState('');
|
||||
const [secretKey, setSecretKey] = useState('');
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [keyFileName, setKeyFileName] = useState('');
|
||||
const [error, setError] = useState(
|
||||
t('It is required to provide both the Application ID and the secret key.'),
|
||||
);
|
||||
|
||||
async function onFileChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
setSecretKey(text);
|
||||
setKeyFileName(file.name);
|
||||
setIsValid(true);
|
||||
} catch {
|
||||
setSecretKey('');
|
||||
setKeyFileName('');
|
||||
setIsValid(false);
|
||||
setError(t('Failed to read the key file. Please try again.'));
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit(close: () => void) {
|
||||
if (!applicationId || !secretKey) {
|
||||
setIsValid(false);
|
||||
setError(
|
||||
t(
|
||||
'It is required to provide both the Application ID and the secret key.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const result = await send('enablebanking-configure', {
|
||||
applicationId,
|
||||
secretKey,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setIsValid(false);
|
||||
setError(getSecretsError(result.error, result.reason));
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.data?.error_code) {
|
||||
setIsValid(false);
|
||||
setError(
|
||||
result.data.error_type ||
|
||||
t(
|
||||
'Could not validate the credentials. Please check your Application ID and secret key.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsValid(true);
|
||||
onSuccess();
|
||||
close();
|
||||
} catch {
|
||||
setIsValid(false);
|
||||
setError(
|
||||
t(
|
||||
'Could not validate the credentials. Please check your Application ID and secret key.',
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
name="enablebanking-init"
|
||||
containerProps={{ style: { width: '30vw', minWidth: 420 } }}
|
||||
>
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Set up Enable Banking')}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View style={{ display: 'flex', gap: 10 }}>
|
||||
<Text>
|
||||
<Trans>
|
||||
In order to enable bank sync via Enable Banking (for EU banks)
|
||||
you will need to create application credentials. This can be
|
||||
done by creating an account at{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://enablebanking.com/cp/applications"
|
||||
linkColor="purple"
|
||||
>
|
||||
Enable Banking
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Text>
|
||||
<Trans>
|
||||
When setting up your application, use the following as the
|
||||
redirect URL:
|
||||
</Trans>{' '}
|
||||
<code>{window.location.origin}/enablebanking/auth_callback</code>
|
||||
</Text>
|
||||
|
||||
{window.location.protocol === 'http:' && (
|
||||
<ErrorAlert>
|
||||
<Trans>
|
||||
Enable Banking requires HTTPS for the redirect URL. Your
|
||||
current connection is not secure.
|
||||
</Trans>
|
||||
</ErrorAlert>
|
||||
)}
|
||||
|
||||
<FormField>
|
||||
<FormLabel
|
||||
title={t('Application ID:')}
|
||||
htmlFor="application-id-field"
|
||||
/>
|
||||
<InitialFocus>
|
||||
<Input
|
||||
id="application-id-field"
|
||||
type="text"
|
||||
value={applicationId}
|
||||
onChangeValue={value => {
|
||||
setApplicationId(value);
|
||||
setIsValid(true);
|
||||
}}
|
||||
/>
|
||||
</InitialFocus>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<FormLabel
|
||||
title={t('Secret Key (.pem file):')}
|
||||
htmlFor="secret-key-field"
|
||||
/>
|
||||
<input
|
||||
id="secret-key-field"
|
||||
type="file"
|
||||
accept=".pem,.key"
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{secretKey && (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<SvgCheckCircle1
|
||||
style={{ width: 14, height: 14, color: theme.noticeText }}
|
||||
/>
|
||||
<Text style={{ fontSize: 12, color: theme.pageTextSubdued }}>
|
||||
{keyFileName}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!isValid && <ErrorAlert>{error}</ErrorAlert>}
|
||||
</View>
|
||||
|
||||
<ModalButtons>
|
||||
<ButtonWithLoading
|
||||
variant="primary"
|
||||
isLoading={isLoading}
|
||||
onPress={() => {
|
||||
void onSubmit(() => state.close());
|
||||
}}
|
||||
>
|
||||
<Trans>Save and continue</Trans>
|
||||
</ButtonWithLoading>
|
||||
</ModalButtons>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { View } from '@actual-app/components/view';
|
||||
import { currentDay, subDays } from '@actual-app/core/shared/months';
|
||||
import type {
|
||||
AccountEntity,
|
||||
SyncServerEnableBankingAccount,
|
||||
SyncServerGoCardlessAccount,
|
||||
SyncServerPluggyAiAccount,
|
||||
SyncServerSimpleFinAccount,
|
||||
@@ -21,7 +20,6 @@ import type {
|
||||
import { format as formatDate, parseISO } from 'date-fns';
|
||||
|
||||
import {
|
||||
useLinkAccountEnableBankingMutation,
|
||||
useLinkAccountMutation,
|
||||
useLinkAccountPluggyAiMutation,
|
||||
useLinkAccountSimpleFinMutation,
|
||||
@@ -89,12 +87,6 @@ export type SelectLinkedAccountsModalProps =
|
||||
externalAccounts: SyncServerPluggyAiAccount[];
|
||||
syncSource: 'pluggyai';
|
||||
upgradingAccountId?: string;
|
||||
}
|
||||
| {
|
||||
requisitionId?: undefined;
|
||||
externalAccounts: SyncServerEnableBankingAccount[];
|
||||
syncSource: 'enableBanking';
|
||||
upgradingAccountId?: string;
|
||||
};
|
||||
|
||||
export function SelectLinkedAccountsModal({
|
||||
@@ -131,12 +123,6 @@ 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)}`);
|
||||
}
|
||||
@@ -194,7 +180,6 @@ export function SelectLinkedAccountsModal({
|
||||
const unlinkAccount = useUnlinkAccountMutation();
|
||||
const linkAccountSimpleFin = useLinkAccountSimpleFinMutation();
|
||||
const linkAccountPluggyAi = useLinkAccountPluggyAiMutation();
|
||||
const linkAccountEnableBanking = useLinkAccountEnableBankingMutation();
|
||||
|
||||
async function onNext() {
|
||||
const chosenLocalAccountIds = Object.values(chosenAccounts);
|
||||
@@ -260,23 +245,6 @@ 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,
|
||||
@@ -532,8 +500,7 @@ export function SelectLinkedAccountsModal({
|
||||
type ExternalAccount =
|
||||
| SyncServerGoCardlessAccount
|
||||
| SyncServerSimpleFinAccount
|
||||
| SyncServerPluggyAiAccount
|
||||
| SyncServerEnableBankingAccount;
|
||||
| SyncServerPluggyAiAccount;
|
||||
|
||||
type StartingBalanceInfo = {
|
||||
date: string;
|
||||
@@ -771,8 +738,7 @@ function getInstitutionName(
|
||||
externalAccount:
|
||||
| SyncServerGoCardlessAccount
|
||||
| SyncServerSimpleFinAccount
|
||||
| SyncServerPluggyAiAccount
|
||||
| SyncServerEnableBankingAccount,
|
||||
| SyncServerPluggyAiAccount,
|
||||
) {
|
||||
if (typeof externalAccount?.institution === 'string') {
|
||||
return externalAccount?.institution ?? '';
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { calculateSpendingReportTimeRange } from './reportRanges';
|
||||
import {
|
||||
calculateSpendingReportTimeRange,
|
||||
calculateTimeRange,
|
||||
} from './reportRanges';
|
||||
|
||||
// In test mode, monthUtils.currentMonth() returns '2017-01'
|
||||
describe('calculateTimeRange', () => {
|
||||
it('keeps last month as a live time range when restoring a saved widget', () => {
|
||||
const [start, end, mode] = calculateTimeRange({
|
||||
start: '2016-11',
|
||||
end: '2016-11',
|
||||
mode: 'lastMonth',
|
||||
});
|
||||
|
||||
expect(start).toBe('2016-12');
|
||||
expect(end).toBe('2016-12');
|
||||
expect(mode).toBe('lastMonth');
|
||||
});
|
||||
});
|
||||
|
||||
// In test mode, monthUtils.currentMonth() returns '2017-01'
|
||||
describe('calculateSpendingReportTimeRange', () => {
|
||||
|
||||
@@ -212,6 +212,10 @@ export function calculateTimeRange(
|
||||
|
||||
return getLatestRange(offset);
|
||||
}
|
||||
if (mode === 'lastMonth') {
|
||||
const lastMonth = monthUtils.subMonths(monthUtils.currentMonth(), 1);
|
||||
return [lastMonth, lastMonth, 'lastMonth'] as const;
|
||||
}
|
||||
if (mode === 'lastYear') {
|
||||
return [
|
||||
monthUtils.getYearStart(monthUtils.prevYear(monthUtils.currentMonth())),
|
||||
|
||||
@@ -228,9 +228,6 @@ export function ExperimentalFeatures() {
|
||||
>
|
||||
<Trans>Payee Locations</Trans>
|
||||
</FeatureToggle>
|
||||
<FeatureToggle flag="enableBanking">
|
||||
<Trans>Enable Banking sync (EU banks)</Trans>
|
||||
</FeatureToggle>
|
||||
{showServerPrefs && (
|
||||
<ServerFeatureToggle
|
||||
prefName="flags.plugins"
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,6 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
|
||||
customThemes: false,
|
||||
budgetAnalysisReport: false,
|
||||
payeeLocations: false,
|
||||
enableBanking: false,
|
||||
sankeyReport: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import type {
|
||||
NoteEntity,
|
||||
RuleEntity,
|
||||
ScheduleEntity,
|
||||
SyncServerEnableBankingAccount,
|
||||
TransactionEntity,
|
||||
UserAccessEntity,
|
||||
UserEntity,
|
||||
@@ -128,31 +127,6 @@ 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: {
|
||||
|
||||
@@ -394,7 +394,6 @@ export default defineConfig(async ({ mode, command }) => {
|
||||
/^\/plugins\/.*$/,
|
||||
/^\/kcab\/.*$/,
|
||||
/^\/plugin-data\/.*$/,
|
||||
/^\/enablebanking\/.*$/,
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -87,6 +87,11 @@ import APIList from './APIList';
|
||||
"deleteSchedule"
|
||||
]} />
|
||||
|
||||
<APIList title="Notes" sections={[
|
||||
"getNote",
|
||||
"updateNote"
|
||||
]} />
|
||||
|
||||
<APIList title="Misc" sections={[
|
||||
"BudgetFile",
|
||||
"initConfig",
|
||||
@@ -730,6 +735,22 @@ Update fields of a rule. `fields` can specify any field described in [`Schedule`
|
||||
|
||||
<Method name="deleteSchedule" args={[{ name: 'id', type: 'id' }]} returns="Promise<null>" />
|
||||
|
||||
## Notes
|
||||
|
||||
Notes can be attached to any entity (categories, budget months, etc.) by ID. They are also used to define budget templates and savings goals (e.g. `#template 250`, `#goal 1000`).
|
||||
|
||||
#### `getNote`
|
||||
|
||||
<Method name="getNote" args={[{ name: 'id', type: 'id' }]} returns="Promise<Note | null>" />
|
||||
|
||||
Returns the note for the given entity ID, or `null` if no note has been set.
|
||||
|
||||
#### `updateNote`
|
||||
|
||||
<Method name="updateNote" args={[{ name: 'id', type: 'id' }, { name: 'note', type: 'string' }]} returns="Promise<void>" />
|
||||
|
||||
Sets the note on the entity with the given ID. Pass an empty string to clear the note.
|
||||
|
||||
## Misc
|
||||
|
||||
#### BudgetFile
|
||||
|
||||
@@ -6,6 +6,10 @@ This guide will help you set up your development environment for contributing to
|
||||
|
||||
## Prerequisites
|
||||
|
||||
:::tip
|
||||
If you prefer not to install Node and Yarn locally, you can use the [Dev Container](#dev-container) or run [Docker Compose](#docker-compose) directly.
|
||||
:::
|
||||
|
||||
Before you begin, ensure you have the following installed:
|
||||
|
||||
- **Node.js**: Version 22 or greater. You can download it from the [Node.js website](https://nodejs.org/en/download) (we recommend the LTS version).
|
||||
@@ -39,6 +43,34 @@ Before you begin, ensure you have the following installed:
|
||||
yarn typecheck
|
||||
```
|
||||
|
||||
## Dev Container
|
||||
|
||||
The repo includes a [`.devcontainer/`](https://github.com/actualbudget/actual/tree/master/.devcontainer) configuration that follows the [Dev Containers spec](https://containers.dev/). Any tool that supports the spec can use it — for example VS Code or Cursor (with the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)), JetBrains IDEs (via Gateway), GitHub Codespaces, or the [`@devcontainers/cli`](https://github.com/devcontainers/cli).
|
||||
|
||||
In an editor that supports the spec, open the cloned repo and accept the **Reopen in Container** prompt (or run the equivalent command from your editor's command palette). The container will build, `yarn install` will run automatically via `postCreateCommand`, and you'll be dropped into a shell with the toolchain ready.
|
||||
|
||||
To start the dev server, open a terminal inside the container and run:
|
||||
|
||||
```bash
|
||||
yarn start
|
||||
```
|
||||
|
||||
The dev server will be available at `http://localhost:3001/`. Most editors automatically forward the port from the container to your host.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
For other editors, run from the repo root:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
This starts a container that runs `yarn start:browser` on port 3001. Open `http://localhost:3001/` in your browser.
|
||||
|
||||
:::note
|
||||
The container mounts your repo at `/app`. If you've already run `yarn install` on your host, the native modules (`better-sqlite3`, `bcrypt`, `electron`, `sharp`) will be compiled for your host OS and won't work inside the Linux container. Either delete `node_modules/` first and let the container reinstall, or run the dev container path above (which rebuilds them automatically).
|
||||
:::
|
||||
|
||||
## Essential Development Commands
|
||||
|
||||
All commands should be run from the **root directory** of the repository. Never run yarn commands from child workspace directories.
|
||||
|
||||
@@ -29,7 +29,6 @@ import type {
|
||||
CategoryEntity,
|
||||
GoCardlessToken,
|
||||
ImportTransactionEntity,
|
||||
SyncServerEnableBankingAccount,
|
||||
SyncServerGoCardlessAccount,
|
||||
SyncServerPluggyAiAccount,
|
||||
SyncServerSimpleFinAccount,
|
||||
@@ -56,7 +55,6 @@ 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;
|
||||
@@ -68,13 +66,6 @@ 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;
|
||||
@@ -373,88 +364,6 @@ 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,
|
||||
@@ -875,183 +784,6 @@ 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');
|
||||
|
||||
@@ -1551,7 +1283,6 @@ 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)));
|
||||
@@ -1563,13 +1294,6 @@ app.method('gocardless-poll-web-token-stop', stopGoCardlessWebTokenPolling);
|
||||
app.method('gocardless-status', goCardlessStatus);
|
||||
app.method('simplefin-status', simpleFinStatus);
|
||||
app.method('pluggyai-status', pluggyAiStatus);
|
||||
app.method('enablebanking-status', enableBankingStatus);
|
||||
app.method('enablebanking-aspsps', enableBankingAspsps);
|
||||
app.method('enablebanking-start-auth', enableBankingStartAuth);
|
||||
app.method('enablebanking-complete-auth', enableBankingCompleteAuth);
|
||||
app.method('enablebanking-poll-auth', enableBankingPollAuth);
|
||||
app.method('enablebanking-poll-auth-stop', stopEnableBankingPollAuth);
|
||||
app.method('enablebanking-configure', enableBankingConfigure);
|
||||
app.method('simplefin-accounts', simpleFinAccounts);
|
||||
app.method('pluggyai-accounts', pluggyAiAccounts);
|
||||
app.method('gocardless-get-banks', getGoCardlessBanks);
|
||||
|
||||
@@ -312,44 +312,6 @@ 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)
|
||||
@@ -1017,19 +979,6 @@ 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];
|
||||
@@ -1127,8 +1076,6 @@ 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}`,
|
||||
|
||||
@@ -707,6 +707,16 @@ handlers['api/category-delete'] = withMutation(async function ({
|
||||
});
|
||||
});
|
||||
|
||||
handlers['api/note-get'] = async function ({ id }) {
|
||||
checkFileOpen();
|
||||
return handlers['notes-get']({ id });
|
||||
};
|
||||
|
||||
handlers['api/note-update'] = withMutation(async function ({ id, note }) {
|
||||
checkFileOpen();
|
||||
return handlers['notes-save']({ id, note });
|
||||
});
|
||||
|
||||
handlers['api/common-payees-get'] = async function () {
|
||||
checkFileOpen();
|
||||
const payees = await handlers['common-payees-get']();
|
||||
|
||||
@@ -7,12 +7,20 @@ import type { NoteEntity } from '#types/models';
|
||||
export type NotesHandlers = {
|
||||
'notes-save': typeof updateNotes;
|
||||
'notes-save-undoable': typeof updateNotes;
|
||||
'notes-get': (arg: Pick<NoteEntity, 'id'>) => Promise<NoteEntity | null>;
|
||||
};
|
||||
|
||||
export const app = createApp<NotesHandlers>();
|
||||
app.method('notes-save', updateNotes);
|
||||
app.method('notes-save-undoable', mutator(undoable(updateNotes)));
|
||||
app.method('notes-get', getNote);
|
||||
|
||||
async function updateNotes({ id, note }: NoteEntity) {
|
||||
await db.update('notes', { id, note });
|
||||
}
|
||||
|
||||
async function getNote({
|
||||
id,
|
||||
}: Pick<NoteEntity, 'id'>): Promise<NoteEntity | null> {
|
||||
return db.first<NoteEntity>('SELECT id, note FROM notes WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
@@ -38,29 +38,14 @@ 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 signal = timeout != null || externalSignal ? controller.signal : null;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
const signal = timeout ? controller.signal : null;
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
@@ -70,19 +55,10 @@ export async function post(
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
text = await res.text();
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.name === 'AbortError' &&
|
||||
externalSignal?.aborted
|
||||
) {
|
||||
throw new PostError('aborted');
|
||||
}
|
||||
} catch {
|
||||
throw new PostError('network-failure');
|
||||
} finally {
|
||||
if (timeoutId != null) clearTimeout(timeoutId);
|
||||
externalSignal?.removeEventListener('abort', onExternalAbort);
|
||||
}
|
||||
|
||||
throwIfNot200(res, text);
|
||||
|
||||
@@ -8,7 +8,6 @@ type ServerConfig = {
|
||||
GOCARDLESS_SERVER: string;
|
||||
SIMPLEFIN_SERVER: string;
|
||||
PLUGGYAI_SERVER: string;
|
||||
ENABLEBANKING_SERVER: string;
|
||||
};
|
||||
|
||||
let config: ServerConfig | null = null;
|
||||
@@ -46,7 +45,6 @@ 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(
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
ImportTransactionEntity,
|
||||
NearbyPayeeEntity,
|
||||
NewRuleEntity,
|
||||
NoteEntity,
|
||||
PayeeEntity,
|
||||
PayeeLocationEntity,
|
||||
RuleEntity,
|
||||
@@ -195,6 +196,10 @@ export type ApiHandlers = {
|
||||
transferCategoryId?: APICategoryEntity['id'];
|
||||
}) => Promise<void>;
|
||||
|
||||
'api/note-get': (arg: Pick<NoteEntity, 'id'>) => Promise<NoteEntity | null>;
|
||||
|
||||
'api/note-update': (arg: NoteEntity) => Promise<void>;
|
||||
|
||||
'api/payees-get': () => Promise<APIPayeeEntity[]>;
|
||||
|
||||
'api/common-payees-get': () => Promise<APIPayeeEntity[]>;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { BankSyncProviders } from './bank-sync';
|
||||
|
||||
export type AccountEntity = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -23,4 +21,4 @@ export type AccountEntity = {
|
||||
last_sync: string | null;
|
||||
};
|
||||
|
||||
export type AccountSyncSource = BankSyncProviders;
|
||||
export type AccountSyncSource = 'simpleFin' | 'goCardless' | 'pluggyai';
|
||||
|
||||
@@ -20,11 +20,4 @@ export type BankSyncResponse = {
|
||||
error_code: string;
|
||||
};
|
||||
|
||||
export const SYNC_PROVIDERS = [
|
||||
'goCardless',
|
||||
'simpleFin',
|
||||
'pluggyai',
|
||||
'enableBanking',
|
||||
] as const;
|
||||
|
||||
export type BankSyncProviders = (typeof SYNC_PROVIDERS)[number];
|
||||
export type BankSyncProviders = 'goCardless' | 'simpleFin' | 'pluggyai';
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// Enable Banking API response types (from https://enablebanking.com/docs/api/reference/)
|
||||
|
||||
export type EnableBankingAccountId = {
|
||||
iban?: string;
|
||||
};
|
||||
|
||||
export type EnableBankingAccountServicer = {
|
||||
bic_fi?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type EnableBankingAspsp = {
|
||||
name: string;
|
||||
country: string;
|
||||
logo?: string;
|
||||
psu_types?: string[];
|
||||
beta?: boolean;
|
||||
maximum_consent_validity?: number;
|
||||
};
|
||||
|
||||
export type EnableBankingSessionAccount = {
|
||||
account_id: EnableBankingAccountId;
|
||||
account_servicer?: EnableBankingAccountServicer;
|
||||
name?: string;
|
||||
currency?: string;
|
||||
uid: string;
|
||||
identification_hash?: string;
|
||||
};
|
||||
|
||||
export type EnableBankingSession = {
|
||||
session_id: string;
|
||||
accounts: EnableBankingSessionAccount[];
|
||||
aspsp: { name: string; country: string };
|
||||
access?: { valid_until: string };
|
||||
};
|
||||
|
||||
export type EnableBankingRawTransaction = {
|
||||
entry_reference?: string;
|
||||
transaction_id?: string;
|
||||
transaction_amount: { currency: string; amount: string };
|
||||
creditor?: { name?: string };
|
||||
debtor?: { name?: string };
|
||||
creditor_account?: { iban?: string };
|
||||
debtor_account?: { iban?: string };
|
||||
credit_debit_indicator?: 'CRDT' | 'DBIT';
|
||||
status: 'BOOK' | 'PDNG';
|
||||
booking_date?: string;
|
||||
value_date?: string;
|
||||
transaction_date?: string;
|
||||
remittance_information?: string[];
|
||||
balance_after_transaction?: { currency: string; amount: string };
|
||||
};
|
||||
|
||||
export type EnableBankingRawBalance = {
|
||||
balance_amount: { currency: string; amount: string };
|
||||
balance_type?: string;
|
||||
reference_date?: string;
|
||||
};
|
||||
|
||||
// Normalized type for client-side account selection (matches SimpleFIN/PluggyAI pattern)
|
||||
export type SyncServerEnableBankingAccount = {
|
||||
account_id: string;
|
||||
name: string;
|
||||
institution: string;
|
||||
balance: number;
|
||||
};
|
||||
@@ -4,7 +4,6 @@ export type * from './bank-sync';
|
||||
export type * from './category';
|
||||
export type * from './category-group';
|
||||
export type * from './dashboard';
|
||||
export type * from './enablebanking';
|
||||
export type * from './gocardless';
|
||||
export type * from './import-transaction';
|
||||
export type * from './nearby-payee';
|
||||
|
||||
@@ -9,7 +9,6 @@ export type FeatureFlag =
|
||||
| 'customThemes'
|
||||
| 'budgetAnalysisReport'
|
||||
| 'payeeLocations'
|
||||
| 'enableBanking'
|
||||
| 'sankeyReport';
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,9 +23,6 @@
|
||||
"#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",
|
||||
@@ -51,9 +48,6 @@
|
||||
"#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",
|
||||
@@ -102,7 +96,6 @@
|
||||
"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",
|
||||
@@ -116,7 +109,6 @@
|
||||
"@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",
|
||||
|
||||
@@ -1,591 +0,0 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,431 +0,0 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
@@ -1,566 +0,0 @@
|
||||
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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
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',
|
||||
};
|
||||
@@ -1,248 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,548 +0,0 @@
|
||||
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',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import { sign } from 'jws';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getJWT } from '#app-enablebanking/utils/jwt';
|
||||
|
||||
// Mock jws to avoid needing real RSA keys
|
||||
vi.mock('jws', () => ({
|
||||
sign: vi.fn(({ header, payload }) => {
|
||||
return `${JSON.stringify(header)}.${JSON.stringify(payload)}.mock-signature`;
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('getJWT', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
it('should call jws.sign with correct header', () => {
|
||||
getJWT('my-app-id', 'my-secret-key');
|
||||
|
||||
expect(sign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
header: {
|
||||
typ: 'JWT',
|
||||
alg: 'RS256',
|
||||
kid: 'my-app-id',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include correct payload fields', () => {
|
||||
getJWT('my-app-id', 'my-secret-key');
|
||||
|
||||
const callArgs = vi.mocked(sign).mock.calls[0][0];
|
||||
const rawPayload = callArgs.payload;
|
||||
const payload: { iss: string; aud: string; iat: number; exp: number } =
|
||||
typeof rawPayload === 'string' ? JSON.parse(rawPayload) : rawPayload;
|
||||
|
||||
expect(payload.iss).toBe('enablebanking.com');
|
||||
expect(payload.aud).toBe('api.enablebanking.com');
|
||||
expect(typeof payload.iat).toBe('number');
|
||||
expect(typeof payload.exp).toBe('number');
|
||||
expect(payload.exp - payload.iat).toBe(3600);
|
||||
});
|
||||
|
||||
it('should use custom expiry', () => {
|
||||
getJWT('my-app-id', 'my-secret-key', 7200);
|
||||
|
||||
const callArgs = vi.mocked(sign).mock.calls[0][0];
|
||||
const rawPayload = callArgs.payload;
|
||||
const payload: { iat: number; exp: number } =
|
||||
typeof rawPayload === 'string' ? JSON.parse(rawPayload) : rawPayload;
|
||||
|
||||
expect(payload.exp - payload.iat).toBe(7200);
|
||||
});
|
||||
|
||||
it('should pass the secret key to jws.sign', () => {
|
||||
getJWT('my-app-id', 'my-secret-key');
|
||||
|
||||
expect(sign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
secret: 'my-secret-key',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return a string', () => {
|
||||
const result = getJWT('my-app-id', 'my-secret-key');
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,6 @@ 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';
|
||||
@@ -60,7 +59,6 @@ app.use('/account', accountApp.handlers);
|
||||
app.use('/gocardless', goCardlessApp.handlers);
|
||||
app.use('/simplefin', simpleFinApp.handlers);
|
||||
app.use('/pluggyai', pluggai.handlers);
|
||||
app.use('/enablebanking', enableBankingApp.handlers);
|
||||
app.use('/secret', secretApp.handlers);
|
||||
|
||||
if (config.get('corsProxy.enabled')) {
|
||||
|
||||
@@ -15,8 +15,6 @@ 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 {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [AurelDemiri]
|
||||
---
|
||||
|
||||
Integrate Enable Banking as a bank sync provider
|
||||
6
upcoming-release-notes/7729.md
Normal file
6
upcoming-release-notes/7729.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [nikhilweee]
|
||||
---
|
||||
|
||||
Document the Dev Container and Docker Compose options as alternatives to local Node and Yarn setup in the contributor development-setup guide.
|
||||
6
upcoming-release-notes/7760.md
Normal file
6
upcoming-release-notes/7760.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [totallynotjon]
|
||||
---
|
||||
|
||||
Fix sporadic text blur in modals by removing unnecessary `will-change: transform` on the modal overlay.
|
||||
6
upcoming-release-notes/7768.md
Normal file
6
upcoming-release-notes/7768.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [ADGJSD]
|
||||
---
|
||||
|
||||
Fix dashboard report widgets saved with the "Last month" live range restoring as static.
|
||||
6
upcoming-release-notes/7769.md
Normal file
6
upcoming-release-notes/7769.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [whlapinel]
|
||||
---
|
||||
|
||||
Add `getNote` and `updateNote` to the public `@actual-app/api`, enabling programmatic read/write of category notes (templates, goals, etc.) without internal API access.
|
||||
6
upcoming-release-notes/7784.md
Normal file
6
upcoming-release-notes/7784.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Fix mobile bank sync indicators not updating live during sync.
|
||||
38
yarn.lock
38
yarn.lock
@@ -186,7 +186,6 @@ __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"
|
||||
@@ -201,7 +200,6 @@ __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"
|
||||
@@ -793,7 +791,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/helper-module-transforms@npm:^7.27.1, @babel/helper-module-transforms@npm:^7.28.3, @babel/helper-module-transforms@npm:^7.28.6":
|
||||
"@babel/helper-module-transforms@npm:^7.27.1, @babel/helper-module-transforms@npm:^7.28.6":
|
||||
version: 7.28.6
|
||||
resolution: "@babel/helper-module-transforms@npm:7.28.6"
|
||||
dependencies:
|
||||
@@ -822,6 +820,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/helper-plugin-utils@npm:^7.28.6":
|
||||
version: 7.28.6
|
||||
resolution: "@babel/helper-plugin-utils@npm:7.28.6"
|
||||
checksum: 10/21c853bbc13dbdddf03309c9a0477270124ad48989e1ad6524b83e83a77524b333f92edd2caae645c5a7ecf264ec6d04a9ebe15aeb54c7f33c037b71ec521e4a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/helper-remap-async-to-generator@npm:^7.27.1":
|
||||
version: 7.27.1
|
||||
resolution: "@babel/helper-remap-async-to-generator@npm:7.27.1"
|
||||
@@ -1354,16 +1359,16 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"@babel/plugin-transform-modules-systemjs@npm:^7.28.5":
|
||||
version: 7.28.5
|
||||
resolution: "@babel/plugin-transform-modules-systemjs@npm:7.28.5"
|
||||
version: 7.29.4
|
||||
resolution: "@babel/plugin-transform-modules-systemjs@npm:7.29.4"
|
||||
dependencies:
|
||||
"@babel/helper-module-transforms": "npm:^7.28.3"
|
||||
"@babel/helper-plugin-utils": "npm:^7.27.1"
|
||||
"@babel/helper-module-transforms": "npm:^7.28.6"
|
||||
"@babel/helper-plugin-utils": "npm:^7.28.6"
|
||||
"@babel/helper-validator-identifier": "npm:^7.28.5"
|
||||
"@babel/traverse": "npm:^7.28.5"
|
||||
"@babel/traverse": "npm:^7.29.0"
|
||||
peerDependencies:
|
||||
"@babel/core": ^7.0.0-0
|
||||
checksum: 10/1b91b4848845eaf6e21663d97a2a6c896553b127deaf3c2e9a2a4f041249277d13ebf71fd42d0ecbc4385e9f76093eff592fe0da0dcf1401b3f38c1615d8c539
|
||||
checksum: 10/79269e6ec8ec831bb63bf1c7cc1a980e28da785e92b36d42612f0139e4044499b99aa109fca849e1a156c092aabf6c24d145f4cabf2ac9ea84ef468852fe4c03
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9842,15 +9847,6 @@ __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"
|
||||
@@ -16290,9 +16286,9 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"fast-uri@npm:^3.0.1":
|
||||
version: 3.1.0
|
||||
resolution: "fast-uri@npm:3.1.0"
|
||||
checksum: 10/818b2c96dc913bcf8511d844c3d2420e2c70b325c0653633f51821e4e29013c2015387944435cd0ef5322c36c9beecc31e44f71b257aeb8e0b333c1d62bb17c2
|
||||
version: 3.1.2
|
||||
resolution: "fast-uri@npm:3.1.2"
|
||||
checksum: 10/1dff04865b2a38d3e0659deadfbf72efdf83a776bfbf9667e4aa9e5a3ec31bc341cda9622136b32b7652a857c8ba11896794186e8f876f8b2b72731fce8622f6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user