Compare commits

..

9 Commits

Author SHA1 Message Date
Claude
320d66444a [AI] Drop verbose comment on SimpleFin sync dispatch
https://claude.ai/code/session_01DNkRSgqW5JEtYpZjxvj7Bi
2026-05-09 20:07:51 +00:00
Claude
17198863a4 [AI] Update release notes filename and author
https://claude.ai/code/session_01DNkRSgqW5JEtYpZjxvj7Bi
2026-05-09 20:04:41 +00:00
Claude
a9f8ae0e21 [AI] Update mobile bank sync indicators live during sync
Mobile's account list uses react-aria-components ListBox with the
items render-function pattern, which memoizes rows by item identity.
Without a dependencies prop, changes to syncingAccountIds,
failedAccounts, and updatedAccounts in Redux didn't cause the
per-account dots to re-render until the items array itself changed,
so the green/yellow/red indicators only updated after the full sync
finished.

Pass these Redux selections via the dependencies prop so the rows
re-render as state changes during sync. Also clear SimpleFin
accounts from accountsSyncing right after the batch call returns,
so their indicators reflect completion before the per-account loop
starts on the remaining accounts.

https://claude.ai/code/session_01DNkRSgqW5JEtYpZjxvj7Bi
2026-05-09 18:04:22 +00:00
Will Lapinel
3799b587ec [AI] Add getNote and updateNote to public API (#7769)
* [AI] Add getNote and updateNote to public API

Notes on categories and other entities have no public API surface today.
The internal `notes-save` handler exists and works, but callers outside
the app must reach into undocumented internals to use it.

A concrete motivation: AI assistants driving Actual through an MCP server
(e.g. Claude via @actual-app/api) can set budget templates and savings
goals on categories by writing specially-formatted strings to the notes
field (e.g. `#template 250`, `#goal 1000`). Without a public API this
requires using the private `lib.send('notes-save', …)` path, which is
fragile and not guaranteed to stay stable.

This commit adds two public methods:
- `getNote(id)` — returns the NoteEntity for a given entity id, or null
- `updateNote(id, note)` — sets the note string on any entity by id

Implementation:
- Adds `notes-get` handler in `packages/loot-core/src/server/notes/app.ts`
- Adds `api/note-get` and `api/note-update` handlers in `api.ts`
- Adds `ApiHandlers` types for both new handlers
- Exposes `getNote` / `updateNote` in `packages/api/methods.ts`
- Adds a test covering get (null before set) and set/update round-trip

Testing:
- `yarn typecheck` — passed (10/10 packages, 0 errors)
- `yarn lint:fix` — passed (0 errors)
- `yarn workspace @actual-app/api test` — passed (19/19 tests, including
  the new "Notes: successfully get and update note" test)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* [AI] Add release note for PR #7769

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* [AI] Address review feedback: tighten types and add docs

- Use NoteEntity field types (Pick<NoteEntity, 'id'>, NoteEntity['id'],
  NoteEntity['note']) instead of plain strings throughout
- Rename getNotes -> getNote (singular) in notes/app.ts
- Add Notes section to packages/docs/docs/api/reference.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 16:17:56 +00:00
Jon Bramley
8e1f27f316 Modal Blur remove static will-change: transform (#7760)
* remove static will-change: transform

* Release notes

* Fix text blur in modals by updating CSS

---------

Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
2026-05-09 16:00:23 +00:00
Nikhil Verma
fb95d4c92d [AI] Document Dev Container option in development-setup docs (#7729)
* [AI] Document Dev Container option in development-setup docs

* [AI] Add release notes for #7729

* [AI] Update spell-check dictionary for Codespaces
2026-05-09 15:45:57 +00:00
LIZ
2782d464ab Fix last month report widgets restoring as static (#7768)
* Fix last month report widgets restoring as static

* Add release note
2026-05-09 15:30:04 +00:00
dependabot[bot]
b63f5dd303 Bump fast-uri from 3.1.0 to 3.1.2 (#7762)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-09 15:05:29 +00:00
dependabot[bot]
35a01b0fa6 Bump @babel/plugin-transform-modules-systemjs from 7.28.5 to 7.29.4 (#7776)
Bumps [@babel/plugin-transform-modules-systemjs](https://github.com/babel/babel/tree/HEAD/packages/babel-plugin-transform-modules-systemjs) from 7.28.5 to 7.29.4.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.4/packages/babel-plugin-transform-modules-systemjs)

---
updated-dependencies:
- dependency-name: "@babel/plugin-transform-modules-systemjs"
  dependency-version: 7.29.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-09 15:03:49 +00:00
60 changed files with 215 additions and 4460 deletions

View File

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

View File

@@ -44,6 +44,7 @@ CLP
CMCIFRPAXXX
COBADEFF
CODEOWNERS
Codespaces
COEP
commerzbank
Copiar

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -466,6 +466,7 @@ const AccountList = forwardRef<HTMLDivElement, AccountListProps>(
<ListBox
aria-label={ariaLabel}
items={accounts}
dependencies={[syncingAccountIds, failedAccounts, updatedAccounts]}
dragAndDropHooks={dragAndDropHooks}
ref={ref}
style={{

View File

@@ -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> &rarr;
</Button>
</View>
</View>
);
};
return (
<Modal
name="enablebanking-external-msg"
onClose={handleClose}
containerProps={{ style: { width: '30vw' } }}
>
{({ state }) => (
<>
<ModalHeader
title={t('Link Your Bank')}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View>
<Paragraph style={{ fontSize: 15 }}>
<Trans>
To link your bank account, you will be redirected to a new page
where Enable Banking will ask to connect to your bank. Enable
Banking will not be able to withdraw funds from your accounts.
</Trans>
</Paragraph>
{error && renderError(error, t)}
{waiting || isConfigurationLoading ? (
<View style={{ alignItems: 'center', marginTop: 15 }}>
<AnimatedLoading
color={theme.pageTextDark}
style={{ width: 20, height: 20 }}
/>
<View style={{ marginTop: 10, color: theme.pageText }}>
{isConfigurationLoading
? t('Checking Enable Banking configuration...')
: waiting === 'browser'
? t('Waiting on Enable Banking...')
: waiting === 'accounts'
? t('Loading accounts...')
: null}
</View>
{waiting === 'browser' && (
<Link
variant="text"
onClick={onJump}
style={{ marginTop: 10 }}
>
(
<Trans>
Account linking not opening in a new tab? Click here
</Trans>
)
</Link>
)}
</View>
) : isConfigured || isEnableBankingSetupComplete ? (
renderLinkButton()
) : (
<>
<Paragraph style={{ color: theme.errorText }}>
<Trans>
Enable Banking integration has not yet been configured.
</Trans>
</Paragraph>
<Button variant="primary" onPress={onEnableBankingInit}>
<Trans>Configure Enable Banking integration</Trans>
</Button>
</>
)}
</View>
</>
)}
</Modal>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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.

View 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.

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [ADGJSD]
---
Fix dashboard report widgets saved with the "Last month" live range restoring as static.

View 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.

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [MatissJanis]
---
Fix mobile bank sync indicators not updating live during sync.

View File

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