├── .dockerignore ├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── build.yml │ ├── docker-edge.yml │ ├── docker-release.yml │ ├── lint.yml │ ├── release-notes.yml │ ├── stale.yml │ └── test.yml ├── .gitignore ├── .node-version ├── .prettierrc.json ├── .yarn └── releases │ └── yarn-4.3.1.cjs ├── .yarnrc.yml ├── Dockerfile ├── LICENSE.txt ├── README.md ├── app.js ├── babel.config.json ├── docker-compose.yml ├── docker ├── download-artifacts.sh ├── edge-alpine.Dockerfile ├── edge-ubuntu.Dockerfile ├── stable-alpine.Dockerfile └── stable-ubuntu.Dockerfile ├── jest.config.json ├── jest.global-setup.js ├── jest.global-teardown.js ├── migrations ├── 1694360000000-create-folders.js ├── 1694360479680-create-account-db.js ├── 1694362247011-create-secret-table.js ├── 1702667624000-rename-nordigen-secrets.js ├── 1718889148000-openid.js └── 1719409568000-multiuser.js ├── package.json ├── src ├── account-db.js ├── accounts │ ├── openid.js │ └── password.js ├── app-account.js ├── app-admin.js ├── app-admin.test.js ├── app-gocardless │ ├── README.md │ ├── app-gocardless.js │ ├── bank-factory.js │ ├── banks │ │ ├── abanca_caglesmm.js │ │ ├── abnamro_abnanl2a.js │ │ ├── american_express_aesudef1.js │ │ ├── bancsabadell_bsabesbbb.js │ │ ├── bank.interface.ts │ │ ├── bank_of_ireland_b365_bofiie2d.js │ │ ├── bankinter_bkbkesmm.js │ │ ├── belfius_gkccbebb.js │ │ ├── berliner_sparkasse_beladebexxx.js │ │ ├── bnp_be_gebabebb.js │ │ ├── cbc_cregbebb.js │ │ ├── commerzbank_cobadeff.js │ │ ├── danskebank_dabno22.js │ │ ├── direkt_heladef1822.js │ │ ├── easybank_bawaatww.js │ │ ├── entercard_swednokk.js │ │ ├── fortuneo_ftnofrp1xxx.js │ │ ├── hype_hyeeit22.js │ │ ├── ing_ingbrobu.js │ │ ├── ing_ingddeff.js │ │ ├── ing_pl_ingbplpw.js │ │ ├── integration-bank.js │ │ ├── isybank_itbbitmm.js │ │ ├── kbc_kredbebb.js │ │ ├── lhv-lhvbee22.js │ │ ├── mbank_retail_brexplpw.js │ │ ├── nationwide_naiagb21.js │ │ ├── nbg_ethngraaxxx.js │ │ ├── norwegian_xx_norwnok1.js │ │ ├── revolut_revolt21.js │ │ ├── sandboxfinance_sfin0000.js │ │ ├── seb_kort_bank_ab.js │ │ ├── seb_privat.js │ │ ├── sparnord_spnodk22.js │ │ ├── spk_karlsruhe_karsde66.js │ │ ├── spk_marburg_biedenkopf_heladef1mar.js │ │ ├── spk_worms_alzey_ried_malade51wor.js │ │ ├── ssk_dusseldorf_dussdeddxxx.js │ │ ├── swedbank_habalv22.js │ │ ├── tests │ │ │ ├── abanca_caglesmm.spec.js │ │ │ ├── abnamro_abnanl2a.spec.js │ │ │ ├── bancsabadell_bsabesbbb.spec.js │ │ │ ├── belfius_gkccbebb.spec.js │ │ │ ├── cbc_cregbebb.spec.js │ │ │ ├── commerzbank_cobadeff.spec.js │ │ │ ├── easybank_bawaatww.spec.js │ │ │ ├── fortuneo_ftnofrp1xxx.spec.js │ │ │ ├── ing_ingddeff.spec.js │ │ │ ├── ing_pl_ingbplpw.spec.js │ │ │ ├── integration_bank.spec.js │ │ │ ├── kbc_kredbebb.spec.js │ │ │ ├── lhv-lhvbee22.spec.js │ │ │ ├── mbank_retail_brexplpw.spec.js │ │ │ ├── nationwide_naiagb21.spec.js │ │ │ ├── nbg_ethngraaxxx.spec.js │ │ │ ├── revolut_revolt21.spec.js │ │ │ ├── sandboxfinance_sfin0000.spec.js │ │ │ ├── spk_marburg_biedenkopf_heladef1mar.spec.js │ │ │ ├── ssk_dusseldorf_dussdeddxxx.spec.js │ │ │ ├── swedbank_habalv22.spec.js │ │ │ └── virgin_nrnbgb22.spec.js │ │ ├── util │ │ │ └── extract-payeeName-from-remittanceInfo.js │ │ └── virgin_nrnbgb22.js │ ├── errors.js │ ├── gocardless-node.types.ts │ ├── gocardless.types.ts │ ├── link.html │ ├── services │ │ ├── gocardless-service.js │ │ └── tests │ │ │ ├── fixtures.js │ │ │ └── gocardless-service.spec.js │ ├── tests │ │ ├── bank-factory.spec.js │ │ └── utils.spec.js │ ├── util │ │ └── handle-error.js │ └── utils.js ├── app-openid.js ├── app-secrets.js ├── app-simplefin │ └── app-simplefin.js ├── app-sync.js ├── app-sync.test.js ├── app-sync │ ├── errors.js │ ├── services │ │ └── files-service.js │ ├── tests │ │ └── services │ │ │ └── files-service.test.js │ └── validation.js ├── app.js ├── config-types.ts ├── db.js ├── load-config.js ├── migrations.js ├── run-migrations.js ├── scripts │ ├── disable-openid.js │ ├── enable-openid.js │ ├── health-check.js │ └── reset-password.js ├── secrets.test.js ├── services │ ├── secrets-service.js │ └── user-service.js ├── sql │ └── messages.sql ├── sync-simple.js └── util │ ├── hash.js │ ├── middlewares.js │ ├── paths.js │ ├── payee-name.js │ ├── prompt.js │ ├── title │ ├── index.js │ ├── lower-case.js │ └── specials.js │ └── validate-user.js ├── tsconfig.json ├── upcoming-release-notes ├── 557.md ├── 560.md ├── 566.md └── README.md └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | user-files 3 | server-files 4 | 5 | # Yarn 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/sdks 12 | !.yarn/versions 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_size = 2 9 | indent_style = space 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules/* 2 | **/log/* 3 | **/shared/* 4 | /build 5 | 6 | supervise 7 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | amd: true, 6 | node: true, 7 | jest: true 8 | }, 9 | parser: '@typescript-eslint/parser', 10 | plugins: ['@typescript-eslint', 'prettier'], 11 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 12 | rules: { 13 | 'prettier/prettier': 'error', 14 | '@typescript-eslint/no-unused-vars': [ 15 | 'error', 16 | { 17 | argsIgnorePattern: '^_' 18 | } 19 | ] 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # Funding policies: https://actualbudget.org/docs/contributing/leadership/funding 2 | open_collective: actual 3 | github: actualbudget 4 | 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report also known as an issue or problem. 3 | title: '[Bug]: ' 4 | labels: ['bug'] 5 | body: 6 | - type: markdown 7 | id: intro-md 8 | attributes: 9 | value: | 10 | Thanks for taking the time to fill out this bug report! Please ensure you provide as much information as possible to better assist in confirming and identifying a fix for the bug. 11 | - type: checkboxes 12 | id: existing-issue 13 | attributes: 14 | label: 'Verified issue does not already exist?' 15 | description: 'Please search to see if an issue already exists for the issue you encountered.' 16 | options: 17 | - label: 'I have searched and found no existing issue' 18 | required: true 19 | validations: 20 | required: true 21 | - type: textarea 22 | id: what-happened 23 | attributes: 24 | label: What happened? 25 | description: Also tell us, what did you expect to happen? If you’re reporting an issue with imports, please attach a (redacted) version of the file you’re having trouble importing. You may need to zip it before uploading. 26 | placeholder: Tell us what you see! 27 | value: 'A bug happened!' 28 | validations: 29 | required: true 30 | - type: textarea 31 | id: errors-received 32 | attributes: 33 | label: 'What error did you receive?' 34 | description: 'If you received an error or a message on the screen, please provide that here.' 35 | validations: 36 | required: false 37 | - type: markdown 38 | id: env-info 39 | attributes: 40 | value: '## Environment Details' 41 | - type: dropdown 42 | id: hosting 43 | attributes: 44 | label: Where are you hosting Actual? 45 | description: Where are you running your instance of Actual from? 46 | options: 47 | - Locally via Yarn 48 | - Docker 49 | - Fly.io 50 | - NAS 51 | - Desktop App (Electron) 52 | - Other 53 | validations: 54 | required: false 55 | - type: dropdown 56 | id: browsers 57 | attributes: 58 | label: What browsers are you seeing the problem on? 59 | multiple: true 60 | options: 61 | - Firefox 62 | - Chrome 63 | - Safari 64 | - Microsoft Edge 65 | - Desktop App (Electron) 66 | - Other 67 | - type: dropdown 68 | id: operating-system 69 | attributes: 70 | label: Operating System 71 | description: What operating system are you using? 72 | options: 73 | - Windows 11 74 | - Windows 10 75 | - Mac OSX 76 | - Linux 77 | - Mobile Device 78 | - Other 79 | validations: 80 | required: false 81 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Feature request 4 | url: https://github.com/actualbudget/actual/issues/new/choose 5 | about: Please use the main Actual repository to make feature requests. 6 | - name: Bank-sync issues 7 | url: https://discord.gg/pRYNYr4W5A 8 | about: Is bank-sync not working? Returning too much or too little information? Reach out to the community on Discord. 9 | - name: Support 10 | url: https://discord.gg/pRYNYr4W5A 11 | about: Need help with something? Having troubles setting up? Or perhaps issues using the API? Reach out to the community on Discord. 12 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: '*' 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Install node 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 18 19 | - name: Cache 20 | uses: actions/cache@v4 21 | id: cache 22 | with: 23 | path: '**/node_modules' 24 | key: yarn-v1-${{ hashFiles('**/yarn.lock') }} 25 | - name: Install 26 | run: yarn --immutable 27 | if: steps.cache.outputs.cache-hit != 'true' 28 | - name: Build 29 | run: yarn build 30 | -------------------------------------------------------------------------------- /.github/workflows/docker-edge.yml: -------------------------------------------------------------------------------- 1 | name: Build Edge Docker Image 2 | 3 | # Edge Docker images are built for every commit, and daily 4 | on: 5 | push: 6 | branches: 7 | - master 8 | paths-ignore: 9 | - README.md 10 | - LICENSE.txt 11 | pull_request: 12 | branches: 13 | - master 14 | schedule: 15 | - cron: '0 0 * * *' 16 | workflow_dispatch: 17 | 18 | permissions: 19 | contents: read 20 | packages: write 21 | 22 | env: 23 | IMAGES: | 24 | actualbudget/actual-server 25 | ghcr.io/actualbudget/actual-server 26 | 27 | # Creates the following tags: 28 | # - actual-server:edge 29 | TAGS: | 30 | type=edge,value=edge 31 | type=sha 32 | 33 | jobs: 34 | build: 35 | name: Build Docker image 36 | runs-on: ubuntu-latest 37 | strategy: 38 | matrix: 39 | os: [ubuntu, alpine] 40 | steps: 41 | - uses: actions/checkout@v4 42 | 43 | - name: Set up QEMU 44 | uses: docker/setup-qemu-action@v3 45 | 46 | - name: Set up Docker Buildx 47 | uses: docker/setup-buildx-action@v3 48 | 49 | - name: Docker meta 50 | id: meta 51 | uses: docker/metadata-action@v5 52 | with: 53 | # Push to both Docker Hub and Github Container Registry 54 | images: ${{ env.IMAGES }} 55 | flavor: ${{ matrix.os != 'ubuntu' && format('suffix=-{0}', matrix.os) || '' }} 56 | tags: ${{ env.TAGS }} 57 | 58 | - name: Login to Docker Hub 59 | uses: docker/login-action@v3 60 | if: github.event_name != 'pull_request' 61 | with: 62 | username: ${{ secrets.DOCKERHUB_USERNAME }} 63 | password: ${{ secrets.DOCKERHUB_TOKEN }} 64 | 65 | - name: Login to GitHub Container Registry 66 | uses: docker/login-action@v3 67 | if: github.event_name != 'pull_request' 68 | with: 69 | registry: ghcr.io 70 | username: ${{ github.repository_owner }} 71 | password: ${{ secrets.GITHUB_TOKEN }} 72 | 73 | - name: Download artifacts 74 | run: ./docker/download-artifacts.sh 75 | env: 76 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 77 | 78 | - name: Build and push image 79 | uses: docker/build-push-action@v5 80 | with: 81 | context: . 82 | push: ${{ github.event_name != 'pull_request' }} 83 | file: docker/edge-${{ matrix.os }}.Dockerfile 84 | platforms: linux/amd64,linux/arm64,linux/arm/v7${{ matrix.os == 'alpine' && ',linux/arm/v6' || '' }} 85 | tags: ${{ steps.meta.outputs.tags }} 86 | build-args: | 87 | GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} 88 | -------------------------------------------------------------------------------- /.github/workflows/docker-release.yml: -------------------------------------------------------------------------------- 1 | name: Build Stable Docker Image 2 | 3 | # Stable Docker images are built for every new tag 4 | on: 5 | push: 6 | tags: 7 | - 'v*.*.*' 8 | paths-ignore: 9 | - README.md 10 | - LICENSE.txt 11 | 12 | env: 13 | IMAGES: | 14 | actualbudget/actual-server 15 | ghcr.io/actualbudget/actual-server 16 | 17 | # Creates the following tags: 18 | # - actual-server:latest (see docker/metadata-action flavor inputs, below) 19 | # - actual-server:1.3 20 | # - actual-server:1.3.7 21 | # - actual-server:sha-90dd603 22 | TAGS: | 23 | type=semver,pattern={{version}} 24 | 25 | jobs: 26 | build: 27 | name: Build Docker image 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - name: Set up QEMU 33 | uses: docker/setup-qemu-action@v3 34 | 35 | - name: Set up Docker Buildx 36 | uses: docker/setup-buildx-action@v3 37 | 38 | - name: Docker meta 39 | id: meta 40 | uses: docker/metadata-action@v5 41 | with: 42 | # Push to both Docker Hub and Github Container Registry 43 | images: ${{ env.IMAGES }} 44 | # Automatically update :latest 45 | flavor: latest=true 46 | tags: ${{ env.TAGS }} 47 | 48 | - name: Docker meta for Alpine image 49 | id: alpine-meta 50 | uses: docker/metadata-action@v5 51 | with: 52 | images: ${{ env.IMAGES }} 53 | # Automatically update :latest 54 | flavor: | 55 | latest=true 56 | suffix=-alpine,onlatest=true 57 | tags: ${{ env.TAGS }} 58 | 59 | - name: Login to Docker Hub 60 | uses: docker/login-action@v3 61 | with: 62 | username: ${{ secrets.DOCKERHUB_USERNAME }} 63 | password: ${{ secrets.DOCKERHUB_TOKEN }} 64 | 65 | - name: Login to GitHub Container Registry 66 | uses: docker/login-action@v3 67 | with: 68 | registry: ghcr.io 69 | username: ${{ github.repository_owner }} 70 | password: ${{ secrets.GITHUB_TOKEN }} 71 | 72 | - name: Build and push ubuntu image 73 | uses: docker/build-push-action@v5 74 | with: 75 | context: . 76 | push: true 77 | file: docker/stable-ubuntu.Dockerfile 78 | platforms: linux/amd64,linux/arm64,linux/arm/v7 79 | tags: ${{ steps.meta.outputs.tags }} 80 | 81 | - name: Build and push alpine image 82 | uses: docker/build-push-action@v5 83 | with: 84 | context: . 85 | push: true 86 | file: docker/stable-alpine.Dockerfile 87 | platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 88 | tags: ${{ steps.alpine-meta.outputs.tags }} 89 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Linter 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: '*' 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Install node 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 18 19 | - name: Cache 20 | uses: actions/cache@v4 21 | id: cache 22 | with: 23 | path: '**/node_modules' 24 | key: yarn-v1-${{ hashFiles('**/yarn.lock') }} 25 | - name: Install 26 | run: yarn --immutable 27 | if: steps.cache.outputs.cache-hit != 'true' 28 | - name: Lint 29 | run: yarn lint 30 | -------------------------------------------------------------------------------- /.github/workflows/release-notes.yml: -------------------------------------------------------------------------------- 1 | name: Release notes 2 | 3 | on: 4 | pull_request: 5 | branches: '*' 6 | 7 | jobs: 8 | release-notes: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | - name: Check release notes 14 | if: startsWith(github.head_ref, 'release/') == false 15 | uses: actualbudget/actions/release-notes/check@main 16 | - name: Generate release notes 17 | if: startsWith(github.head_ref, 'release/') == true 18 | uses: actualbudget/actions/release-notes/generate@main 19 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale PRs' 2 | on: 3 | schedule: 4 | - cron: '30 1 * * *' 5 | 6 | jobs: 7 | stale: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/stale@v9 11 | with: 12 | stale-pr-message: 'This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' 13 | close-pr-message: 'This PR was closed because it has been stalled for 5 days with no activity.' 14 | days-before-stale: 30 15 | days-before-close: 5 16 | days-before-issue-stale: -1 17 | stale-wip: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/stale@v9 21 | with: 22 | stale-pr-message: ':wave: Hi! It looks like this PR has not had any changes for a week now. Would you like someone to review this PR? If so - please remove the "[WIP]" prefix from the PR title. That will let the community know that this PR is open for a review.' 23 | days-before-stale: 7 24 | any-of-labels: ':construction: WIP' 25 | days-before-close: -1 26 | days-before-issue-stale: -1 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: '*' 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Install node 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 18 19 | - name: Cache 20 | uses: actions/cache@v4 21 | id: cache 22 | with: 23 | path: '**/node_modules' 24 | key: yarn-v1-${{ hashFiles('**/yarn.lock') }} 25 | - name: Install 26 | run: yarn --immutable 27 | if: steps.cache.outputs.cache-hit != 'true' 28 | - name: Test 29 | run: yarn test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .#* 3 | config.json 4 | node_modules 5 | log 6 | supervise 7 | bin/large-sync-data.txt 8 | user-files 9 | server-files 10 | test-user-files 11 | test-server-files 12 | fly.toml 13 | build/ 14 | *.crt 15 | *.pem 16 | *.key 17 | artifacts.json 18 | .migrate 19 | .migrate-test 20 | 21 | # Yarn 22 | .pnp.* 23 | .yarn/* 24 | !.yarn/patches 25 | !.yarn/plugins 26 | !.yarn/releases 27 | !.yarn/sdks 28 | !.yarn/versions 29 | 30 | dist 31 | .idea 32 | /coverage 33 | /coverage-e2e 34 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 18.14.0 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-4.3.1.cjs 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-bookworm as base 2 | RUN apt-get update && apt-get install -y openssl 3 | WORKDIR /app 4 | COPY .yarn ./.yarn 5 | COPY yarn.lock package.json .yarnrc.yml ./ 6 | RUN yarn workspaces focus --all --production 7 | 8 | FROM node:18-bookworm-slim as prod 9 | RUN apt-get update && apt-get install tini && apt-get clean -y && rm -rf /var/lib/apt/lists/* 10 | 11 | ARG USERNAME=actual 12 | ARG USER_UID=1001 13 | ARG USER_GID=$USER_UID 14 | RUN groupadd --gid $USER_GID $USERNAME \ 15 | && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME 16 | RUN mkdir /data && chown -R ${USERNAME}:${USERNAME} /data 17 | 18 | WORKDIR /app 19 | ENV NODE_ENV production 20 | COPY --from=base /app/node_modules /app/node_modules 21 | COPY package.json app.js ./ 22 | COPY src ./src 23 | COPY migrations ./migrations 24 | ENTRYPOINT ["/usr/bin/tini","-g", "--"] 25 | EXPOSE 5006 26 | CMD ["node", "app.js"] 27 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright James Long 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!IMPORTANT] 2 | > This repository will be merged into [actualbudget/actual](https://github.com/actualbudget/actual/tree/master/packages/sync-server) in February 2025 and placed in a readonly state. For more information please see our [Docs](https://actualbudget.org/docs/actual-server-repo-move) or our [Discord](https://discord.com/invite/pRYNYr4W5A). 3 | 4 | 5 | This is the main project to run [Actual](https://github.com/actualbudget/actual), a local-first personal finance tool. It comes with the latest version of Actual, and a server to persist changes and make data available across all devices. 6 | 7 | ### Getting Started 8 | Actual is a local-first personal finance tool. It is 100% free and open-source, written in NodeJS, it has a synchronization element so that all your changes can move between devices without any heavy lifting. 9 | 10 | If you are interested in contributing, or want to know how development works, see our [contributing](https://actualbudget.org/docs/contributing/) document we would love to have you. 11 | 12 | Want to say thanks? Click the ⭐ at the top of the page. 13 | 14 | ### Documentation 15 | 16 | We have a wide range of documentation on how to use Actual. This is all available in our [Community Documentation](https://actualbudget.org/docs/), including topics on [installing](https://actualbudget.org/docs/install/), [Budgeting](https://actualbudget.org/docs/budgeting/), [Account Management](https://actualbudget.org/docs/accounts/), [Tips & Tricks](https://actualbudget.org/docs/getting-started/tips-tricks) and some documentation for developers. 17 | 18 | ### Feature Requests 19 | Current feature requests can be seen [here](https://github.com/actualbudget/actual/issues?q=is%3Aissue+label%3A%22needs+votes%22+sort%3Areactions-%2B1-desc). Vote for your favorite requests by reacting 👍 to the top comment of the request. 20 | 21 | To add new feature requests, open a new Issue of the "Feature Request" type. 22 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import runMigrations from './src/migrations.js'; 2 | 3 | runMigrations() 4 | .then(() => { 5 | //import the app here becasue initial migrations need to be run first - they are dependencies of the app.js 6 | import('./src/app.js').then((app) => app.default()); // run the app 7 | }) 8 | .catch((err) => { 9 | console.log('Error starting app:', err); 10 | process.exit(1); 11 | }); 12 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | actual_server: 3 | image: docker.io/actualbudget/actual-server:latest 4 | ports: 5 | # This line makes Actual available at port 5006 of the device you run the server on, 6 | # i.e. http://localhost:5006. You can change the first number to change the port, if you want. 7 | - '5006:5006' 8 | environment: 9 | # Uncomment any of the lines below to set configuration options. 10 | # - ACTUAL_HTTPS_KEY=/data/selfhost.key 11 | # - ACTUAL_HTTPS_CERT=/data/selfhost.crt 12 | # - ACTUAL_PORT=5006 13 | # - ACTUAL_UPLOAD_FILE_SYNC_SIZE_LIMIT_MB=20 14 | # - ACTUAL_UPLOAD_SYNC_ENCRYPTED_FILE_SYNC_SIZE_LIMIT_MB=50 15 | # - ACTUAL_UPLOAD_FILE_SIZE_LIMIT_MB=20 16 | # See all options and more details at https://actualbudget.github.io/docs/Installing/Configuration 17 | # !! If you are not using any of these options, remove the 'environment:' tag entirely. 18 | volumes: 19 | # Change './actual-data' below to the path to the folder you want Actual to store its data in on your server. 20 | # '/data' is the path Actual will look for its files in by default, so leave that as-is. 21 | - ./actual-data:/data 22 | healthcheck: 23 | # Enable health check for the instance 24 | test: ["CMD-SHELL", "node src/scripts/health-check.js"] 25 | interval: 60s 26 | timeout: 10s 27 | retries: 3 28 | start_period: 20s 29 | restart: unless-stopped 30 | -------------------------------------------------------------------------------- /docker/download-artifacts.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | URL="https://api.github.com/repos/actualbudget/actual/actions/artifacts?name=actual-web&per_page=100" 4 | 5 | if [ -n "$GITHUB_TOKEN" ]; then 6 | curl -L -o artifacts.json --header "Authorization: Bearer ${GITHUB_TOKEN}" $URL 7 | else 8 | curl -L -o artifacts.json $URL 9 | fi 10 | 11 | if [ $? -ne 0 ]; then 12 | echo "Failed to download artifacts.json" 13 | exit 1 14 | fi 15 | -------------------------------------------------------------------------------- /docker/edge-alpine.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.18 AS base 2 | RUN apk add --no-cache nodejs yarn npm python3 openssl build-base jq curl 3 | WORKDIR /app 4 | COPY .yarn ./.yarn 5 | COPY yarn.lock package.json .yarnrc.yml ./ 6 | RUN if [ "$(uname -m)" = "armv7l" ]; then yarn config set taskPoolConcurrency 2; yarn config set networkConcurrency 5; fi 7 | RUN yarn workspaces focus --all --production 8 | RUN if [ "$(uname -m)" = "armv7l" ]; then npm install bcrypt better-sqlite3 --build-from-source; fi 9 | 10 | RUN mkdir /public 11 | COPY artifacts.json /tmp/artifacts.json 12 | RUN jq -r '[.artifacts[] | select(.workflow_run.head_branch == "master" and .workflow_run.head_repository_id == .workflow_run.repository_id)][0]' /tmp/artifacts.json > /tmp/latest-build.json 13 | 14 | ARG GITHUB_TOKEN 15 | RUN curl -L -o /tmp/desktop-client.zip --header "Authorization: Bearer ${GITHUB_TOKEN}" $(jq -r '.archive_download_url' /tmp/latest-build.json) 16 | RUN unzip /tmp/desktop-client.zip -d /public 17 | 18 | FROM alpine:3.18 AS prod 19 | RUN apk add --no-cache nodejs tini 20 | 21 | ARG USERNAME=actual 22 | ARG USER_UID=1001 23 | ARG USER_GID=$USER_UID 24 | RUN addgroup -S ${USERNAME} -g ${USER_GID} && adduser -S ${USERNAME} -G ${USERNAME} -u ${USER_UID} 25 | RUN mkdir /data && chown -R ${USERNAME}:${USERNAME} /data 26 | 27 | WORKDIR /app 28 | ENV NODE_ENV production 29 | COPY --from=base /app/node_modules /app/node_modules 30 | COPY --from=base /public /public 31 | COPY package.json app.js ./ 32 | COPY src ./src 33 | COPY migrations ./migrations 34 | ENTRYPOINT ["/sbin/tini","-g", "--"] 35 | ENV ACTUAL_WEB_ROOT=/public 36 | EXPOSE 5006 37 | CMD ["node", "app.js"] 38 | -------------------------------------------------------------------------------- /docker/edge-ubuntu.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-bookworm AS base 2 | RUN apt-get update && apt-get install -y openssl jq 3 | WORKDIR /app 4 | COPY .yarn ./.yarn 5 | COPY yarn.lock package.json .yarnrc.yml ./ 6 | RUN if [ "$(uname -m)" = "armv7l" ]; then yarn config set taskPoolConcurrency 2; yarn config set networkConcurrency 5; fi 7 | RUN yarn workspaces focus --all --production 8 | 9 | RUN mkdir /public 10 | COPY artifacts.json /tmp/artifacts.json 11 | RUN jq -r '[.artifacts[] | select(.workflow_run.head_branch == "master" and .workflow_run.head_repository_id == .workflow_run.repository_id)][0]' /tmp/artifacts.json > /tmp/latest-build.json 12 | 13 | ARG GITHUB_TOKEN 14 | RUN curl -L -o /tmp/desktop-client.zip --header "Authorization: Bearer ${GITHUB_TOKEN}" $(jq -r '.archive_download_url' /tmp/latest-build.json) 15 | RUN unzip /tmp/desktop-client.zip -d /public 16 | 17 | FROM node:18-bookworm-slim AS prod 18 | RUN apt-get update && apt-get install tini && apt-get clean -y && rm -rf /var/lib/apt/lists/* 19 | 20 | ARG USERNAME=actual 21 | ARG USER_UID=1001 22 | ARG USER_GID=$USER_UID 23 | RUN groupadd --gid $USER_GID $USERNAME \ 24 | && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME 25 | RUN mkdir /data && chown -R ${USERNAME}:${USERNAME} /data 26 | 27 | WORKDIR /app 28 | ENV NODE_ENV production 29 | COPY --from=base /app/node_modules /app/node_modules 30 | COPY --from=base /public /public 31 | COPY package.json app.js ./ 32 | COPY src ./src 33 | COPY migrations ./migrations 34 | ENTRYPOINT ["/usr/bin/tini","-g", "--"] 35 | ENV ACTUAL_WEB_ROOT=/public 36 | EXPOSE 5006 37 | CMD ["node", "app.js"] 38 | -------------------------------------------------------------------------------- /docker/stable-alpine.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.18 AS base 2 | RUN apk add --no-cache nodejs yarn npm python3 openssl build-base 3 | WORKDIR /app 4 | COPY .yarn ./.yarn 5 | COPY yarn.lock package.json .yarnrc.yml ./ 6 | RUN if [ "$(uname -m)" = "armv7l" ]; then yarn config set taskPoolConcurrency 2; yarn config set networkConcurrency 5; fi 7 | RUN yarn workspaces focus --all --production 8 | RUN if [ "$(uname -m)" = "armv7l" ]; then npm install bcrypt better-sqlite3 --build-from-source; fi 9 | 10 | FROM alpine:3.18 AS prod 11 | RUN apk add --no-cache nodejs tini 12 | 13 | ARG USERNAME=actual 14 | ARG USER_UID=1001 15 | ARG USER_GID=$USER_UID 16 | RUN addgroup -S ${USERNAME} -g ${USER_GID} && adduser -S ${USERNAME} -G ${USERNAME} -u ${USER_UID} 17 | RUN mkdir /data && chown -R ${USERNAME}:${USERNAME} /data 18 | 19 | WORKDIR /app 20 | ENV NODE_ENV production 21 | COPY --from=base /app/node_modules /app/node_modules 22 | COPY package.json app.js ./ 23 | COPY src ./src 24 | COPY migrations ./migrations 25 | ENTRYPOINT ["/sbin/tini","-g", "--"] 26 | EXPOSE 5006 27 | CMD ["node", "app.js"] 28 | -------------------------------------------------------------------------------- /docker/stable-ubuntu.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-bookworm AS base 2 | RUN apt-get update && apt-get install -y openssl 3 | WORKDIR /app 4 | COPY .yarn ./.yarn 5 | COPY yarn.lock package.json .yarnrc.yml ./ 6 | RUN if [ "$(uname -m)" = "armv7l" ]; then yarn config set taskPoolConcurrency 2; yarn config set networkConcurrency 5; fi 7 | RUN yarn workspaces focus --all --production 8 | 9 | FROM node:18-bookworm-slim AS prod 10 | RUN apt-get update && apt-get install tini && apt-get clean -y && rm -rf /var/lib/apt/lists/* 11 | 12 | ARG USERNAME=actual 13 | ARG USER_UID=1001 14 | ARG USER_GID=$USER_UID 15 | RUN groupadd --gid $USER_GID $USERNAME \ 16 | && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME 17 | RUN mkdir /data && chown -R ${USERNAME}:${USERNAME} /data 18 | 19 | WORKDIR /app 20 | ENV NODE_ENV production 21 | COPY --from=base /app/node_modules /app/node_modules 22 | COPY package.json app.js ./ 23 | COPY src ./src 24 | COPY migrations ./migrations 25 | ENTRYPOINT ["/usr/bin/tini","-g", "--"] 26 | EXPOSE 5006 27 | CMD ["node", "app.js"] 28 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "globalSetup": "./jest.global-setup.js", 3 | "globalTeardown": "./jest.global-teardown.js", 4 | "testPathIgnorePatterns": ["dist", "/node_modules/", "/build/"], 5 | "roots": [""], 6 | "moduleFileExtensions": ["ts", "js", "json"], 7 | "testEnvironment": "node", 8 | "collectCoverage": true, 9 | "collectCoverageFrom": ["**/*.{js,ts,tsx}"], 10 | "coveragePathIgnorePatterns": ["dist", "/node_modules/", "/build/", "/coverage/"], 11 | "coverageReporters": ["html", "lcov", "text", "text-summary"], 12 | "resetMocks": true, 13 | "restoreMocks": true 14 | } 15 | -------------------------------------------------------------------------------- /jest.global-setup.js: -------------------------------------------------------------------------------- 1 | import getAccountDb from './src/account-db.js'; 2 | import runMigrations from './src/migrations.js'; 3 | 4 | const GENERIC_ADMIN_ID = 'genericAdmin'; 5 | const GENERIC_USER_ID = 'genericUser'; 6 | const ADMIN_ROLE_ID = 'ADMIN'; 7 | const BASIC_ROLE_ID = 'BASIC'; 8 | 9 | const createUser = (userId, userName, role, owner = 0, enabled = 1) => { 10 | const missingParams = []; 11 | if (!userId) missingParams.push('userId'); 12 | if (!userName) missingParams.push('userName'); 13 | if (!role) missingParams.push('role'); 14 | if (missingParams.length > 0) { 15 | throw new Error(`Missing required parameters: ${missingParams.join(', ')}`); 16 | } 17 | 18 | if ( 19 | typeof userId !== 'string' || 20 | typeof userName !== 'string' || 21 | typeof role !== 'string' 22 | ) { 23 | throw new Error( 24 | 'Invalid parameter types. userId, userName, and role must be strings', 25 | ); 26 | } 27 | 28 | try { 29 | getAccountDb().mutate( 30 | 'INSERT INTO users (id, user_name, display_name, enabled, owner, role) VALUES (?, ?, ?, ?, ?, ?)', 31 | [userId, userName, userName, enabled, owner, role], 32 | ); 33 | } catch (error) { 34 | console.error(`Error creating user ${userName}:`, error); 35 | throw error; 36 | } 37 | }; 38 | 39 | const setSessionUser = (userId, token = 'valid-token') => { 40 | if (!userId) { 41 | throw new Error('userId is required'); 42 | } 43 | 44 | try { 45 | const db = getAccountDb(); 46 | const session = db.first('SELECT token FROM sessions WHERE token = ?', [ 47 | token, 48 | ]); 49 | if (!session) { 50 | throw new Error(`Session not found for token: ${token}`); 51 | } 52 | 53 | db.mutate('UPDATE sessions SET user_id = ? WHERE token = ?', [ 54 | userId, 55 | token, 56 | ]); 57 | } catch (error) { 58 | console.error(`Error updating session for user ${userId}:`, error); 59 | throw error; 60 | } 61 | }; 62 | 63 | export default async function setup() { 64 | const NEVER_EXPIRES = -1; // or consider using a far future timestamp 65 | 66 | await runMigrations(); 67 | 68 | createUser(GENERIC_ADMIN_ID, 'admin', ADMIN_ROLE_ID, 1); 69 | 70 | // Insert a fake "valid-token" fixture that can be reused 71 | const db = getAccountDb(); 72 | try { 73 | await db.mutate('BEGIN TRANSACTION'); 74 | 75 | await db.mutate('DELETE FROM sessions'); 76 | await db.mutate( 77 | 'INSERT INTO sessions (token, expires_at, user_id) VALUES (?, ?, ?)', 78 | ['valid-token', NEVER_EXPIRES, 'genericAdmin'], 79 | ); 80 | await db.mutate( 81 | 'INSERT INTO sessions (token, expires_at, user_id) VALUES (?, ?, ?)', 82 | ['valid-token-admin', NEVER_EXPIRES, 'genericAdmin'], 83 | ); 84 | 85 | await db.mutate( 86 | 'INSERT INTO sessions (token, expires_at, user_id) VALUES (?, ?, ?)', 87 | ['valid-token-user', NEVER_EXPIRES, 'genericUser'], 88 | ); 89 | 90 | await db.mutate('COMMIT'); 91 | } catch (error) { 92 | await db.mutate('ROLLBACK'); 93 | throw new Error(`Failed to setup test sessions: ${error.message}`); 94 | } 95 | 96 | setSessionUser('genericAdmin'); 97 | setSessionUser('genericAdmin', 'valid-token-admin'); 98 | 99 | createUser(GENERIC_USER_ID, 'user', BASIC_ROLE_ID, 1); 100 | } 101 | -------------------------------------------------------------------------------- /jest.global-teardown.js: -------------------------------------------------------------------------------- 1 | import runMigrations from './src/migrations.js'; 2 | 3 | export default async function teardown() { 4 | await runMigrations('down'); 5 | } 6 | -------------------------------------------------------------------------------- /migrations/1694360000000-create-folders.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | import config from '../src/load-config.js'; 3 | 4 | async function ensureExists(path) { 5 | try { 6 | await fs.mkdir(path); 7 | } catch (err) { 8 | if (err.code == 'EEXIST') { 9 | return null; 10 | } 11 | 12 | throw err; 13 | } 14 | } 15 | 16 | export const up = async function () { 17 | await ensureExists(config.serverFiles); 18 | await ensureExists(config.userFiles); 19 | }; 20 | 21 | export const down = async function () { 22 | await fs.rm(config.serverFiles, { recursive: true, force: true }); 23 | await fs.rm(config.userFiles, { recursive: true, force: true }); 24 | }; 25 | -------------------------------------------------------------------------------- /migrations/1694360479680-create-account-db.js: -------------------------------------------------------------------------------- 1 | import getAccountDb from '../src/account-db.js'; 2 | 3 | export const up = async function () { 4 | await getAccountDb().exec(` 5 | CREATE TABLE IF NOT EXISTS auth 6 | (password TEXT PRIMARY KEY); 7 | 8 | CREATE TABLE IF NOT EXISTS sessions 9 | (token TEXT PRIMARY KEY); 10 | 11 | CREATE TABLE IF NOT EXISTS files 12 | (id TEXT PRIMARY KEY, 13 | group_id TEXT, 14 | sync_version SMALLINT, 15 | encrypt_meta TEXT, 16 | encrypt_keyid TEXT, 17 | encrypt_salt TEXT, 18 | encrypt_test TEXT, 19 | deleted BOOLEAN DEFAULT FALSE, 20 | name TEXT); 21 | `); 22 | }; 23 | 24 | export const down = async function () { 25 | await getAccountDb().exec(` 26 | DROP TABLE auth; 27 | DROP TABLE sessions; 28 | DROP TABLE files; 29 | `); 30 | }; 31 | -------------------------------------------------------------------------------- /migrations/1694362247011-create-secret-table.js: -------------------------------------------------------------------------------- 1 | import getAccountDb from '../src/account-db.js'; 2 | 3 | export const up = async function () { 4 | await getAccountDb().exec(` 5 | CREATE TABLE IF NOT EXISTS secrets ( 6 | name TEXT PRIMARY KEY, 7 | value BLOB 8 | ); 9 | `); 10 | }; 11 | 12 | export const down = async function () { 13 | await getAccountDb().exec(` 14 | DROP TABLE secrets; 15 | `); 16 | }; 17 | -------------------------------------------------------------------------------- /migrations/1702667624000-rename-nordigen-secrets.js: -------------------------------------------------------------------------------- 1 | import getAccountDb from '../src/account-db.js'; 2 | 3 | export const up = async function () { 4 | await getAccountDb().exec( 5 | `UPDATE secrets SET name = 'gocardless_secretId' WHERE name = 'nordigen_secretId'`, 6 | ); 7 | await getAccountDb().exec( 8 | `UPDATE secrets SET name = 'gocardless_secretKey' WHERE name = 'nordigen_secretKey'`, 9 | ); 10 | }; 11 | 12 | export const down = async function () { 13 | await getAccountDb().exec( 14 | `UPDATE secrets SET name = 'nordigen_secretId' WHERE name = 'gocardless_secretId'`, 15 | ); 16 | await getAccountDb().exec( 17 | `UPDATE secrets SET name = 'nordigen_secretKey' WHERE name = 'gocardless_secretKey'`, 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /migrations/1718889148000-openid.js: -------------------------------------------------------------------------------- 1 | import getAccountDb from '../src/account-db.js'; 2 | 3 | export const up = async function () { 4 | await getAccountDb().exec( 5 | ` 6 | BEGIN TRANSACTION; 7 | CREATE TABLE auth_new 8 | (method TEXT PRIMARY KEY, 9 | display_name TEXT, 10 | extra_data TEXT, active INTEGER); 11 | 12 | INSERT INTO auth_new (method, display_name, extra_data, active) 13 | SELECT 'password', 'Password', password, 1 FROM auth; 14 | DROP TABLE auth; 15 | ALTER TABLE auth_new RENAME TO auth; 16 | 17 | CREATE TABLE pending_openid_requests 18 | (state TEXT PRIMARY KEY, 19 | code_verifier TEXT, 20 | return_url TEXT, 21 | expiry_time INTEGER); 22 | COMMIT;`, 23 | ); 24 | }; 25 | 26 | export const down = async function () { 27 | await getAccountDb().exec( 28 | ` 29 | BEGIN TRANSACTION; 30 | ALTER TABLE auth RENAME TO auth_temp; 31 | CREATE TABLE auth 32 | (password TEXT); 33 | INSERT INTO auth (password) 34 | SELECT extra_data FROM auth_temp WHERE method = 'password'; 35 | DROP TABLE auth_temp; 36 | 37 | DROP TABLE pending_openid_requests; 38 | COMMIT; 39 | `, 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /migrations/1719409568000-multiuser.js: -------------------------------------------------------------------------------- 1 | import getAccountDb from '../src/account-db.js'; 2 | import * as uuid from 'uuid'; 3 | 4 | export const up = async function () { 5 | const accountDb = getAccountDb(); 6 | 7 | accountDb.transaction(() => { 8 | accountDb.exec( 9 | ` 10 | CREATE TABLE users 11 | (id TEXT PRIMARY KEY, 12 | user_name TEXT, 13 | display_name TEXT, 14 | role TEXT, 15 | enabled INTEGER NOT NULL DEFAULT 1, 16 | owner INTEGER NOT NULL DEFAULT 0); 17 | 18 | CREATE TABLE user_access 19 | (user_id TEXT, 20 | file_id TEXT, 21 | PRIMARY KEY (user_id, file_id), 22 | FOREIGN KEY (user_id) REFERENCES users(id), 23 | FOREIGN KEY (file_id) REFERENCES files(id) 24 | ); 25 | 26 | ALTER TABLE files 27 | ADD COLUMN owner TEXT; 28 | 29 | ALTER TABLE sessions 30 | ADD COLUMN expires_at INTEGER; 31 | 32 | ALTER TABLE sessions 33 | ADD COLUMN user_id TEXT; 34 | 35 | ALTER TABLE sessions 36 | ADD COLUMN auth_method TEXT; 37 | `, 38 | ); 39 | 40 | const userId = uuid.v4(); 41 | accountDb.mutate( 42 | 'INSERT INTO users (id, user_name, display_name, enabled, owner, role) VALUES (?, ?, ?, 1, 1, ?)', 43 | [userId, '', '', 'ADMIN'], 44 | ); 45 | 46 | accountDb.mutate( 47 | 'UPDATE sessions SET user_id = ?, expires_at = ?, auth_method = ? WHERE auth_method IS NULL', 48 | [userId, -1, 'password'], 49 | ); 50 | }); 51 | }; 52 | 53 | export const down = async function () { 54 | await getAccountDb().exec( 55 | ` 56 | BEGIN TRANSACTION; 57 | 58 | DROP TABLE IF EXISTS user_access; 59 | 60 | CREATE TABLE sessions_backup ( 61 | token TEXT PRIMARY KEY 62 | ); 63 | 64 | INSERT INTO sessions_backup (token) 65 | SELECT token FROM sessions; 66 | 67 | DROP TABLE sessions; 68 | 69 | ALTER TABLE sessions_backup RENAME TO sessions; 70 | 71 | CREATE TABLE files_backup ( 72 | id TEXT PRIMARY KEY, 73 | group_id TEXT, 74 | sync_version SMALLINT, 75 | encrypt_meta TEXT, 76 | encrypt_keyid TEXT, 77 | encrypt_salt TEXT, 78 | encrypt_test TEXT, 79 | deleted BOOLEAN DEFAULT FALSE, 80 | name TEXT 81 | ); 82 | 83 | INSERT INTO files_backup ( 84 | id, 85 | group_id, 86 | sync_version, 87 | encrypt_meta, 88 | encrypt_keyid, 89 | encrypt_salt, 90 | encrypt_test, 91 | deleted, 92 | name 93 | ) 94 | SELECT 95 | id, 96 | group_id, 97 | sync_version, 98 | encrypt_meta, 99 | encrypt_keyid, 100 | encrypt_salt, 101 | encrypt_test, 102 | deleted, 103 | name 104 | FROM files; 105 | 106 | DROP TABLE files; 107 | 108 | ALTER TABLE files_backup RENAME TO files; 109 | 110 | DROP TABLE IF EXISTS users; 111 | 112 | COMMIT; 113 | `, 114 | ); 115 | }; 116 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "actual-sync", 3 | "version": "25.2.1", 4 | "license": "MIT", 5 | "description": "actual syncing server", 6 | "type": "module", 7 | "scripts": { 8 | "start": "node app", 9 | "lint": "eslint . --max-warnings 0", 10 | "lint:fix": "eslint . --fix", 11 | "build": "tsc", 12 | "test": "NODE_ENV=test NODE_OPTIONS='--experimental-vm-modules --trace-warnings' jest --coverage", 13 | "db:migrate": "NODE_ENV=development node src/run-migrations.js up", 14 | "db:downgrade": "NODE_ENV=development node src/run-migrations.js down", 15 | "db:test-migrate": "NODE_ENV=test node src/run-migrations.js up", 16 | "db:test-downgrade": "NODE_ENV=test node src/run-migrations.js down", 17 | "types": "tsc --noEmit --incremental", 18 | "verify": "yarn lint && yarn types", 19 | "reset-password": "node src/scripts/reset-password.js", 20 | "enable-openid": "node src/scripts/enable-openid.js", 21 | "disable-openid": "node src/scripts/disable-openid.js", 22 | "health-check": "node src/scripts/health-check.js" 23 | }, 24 | "dependencies": { 25 | "@actual-app/crdt": "2.1.0", 26 | "@actual-app/web": "25.2.1", 27 | "bcrypt": "^5.1.1", 28 | "better-sqlite3": "^11.7.0", 29 | "body-parser": "^1.20.3", 30 | "cors": "^2.8.5", 31 | "date-fns": "^2.30.0", 32 | "debug": "^4.3.4", 33 | "express": "4.20.0", 34 | "express-actuator": "1.8.4", 35 | "express-rate-limit": "^6.7.0", 36 | "express-response-size": "^0.0.3", 37 | "express-winston": "^4.2.0", 38 | "jws": "^4.0.0", 39 | "migrate": "^2.0.1", 40 | "nordigen-node": "^1.4.0", 41 | "openid-client": "^5.4.2", 42 | "uuid": "^9.0.0", 43 | "winston": "^3.14.2" 44 | }, 45 | "devDependencies": { 46 | "@babel/preset-typescript": "^7.20.2", 47 | "@types/bcrypt": "^5.0.2", 48 | "@types/better-sqlite3": "^7.6.12", 49 | "@types/cors": "^2.8.13", 50 | "@types/express": "^4.17.17", 51 | "@types/express-actuator": "^1.8.0", 52 | "@types/jest": "^29.2.3", 53 | "@types/node": "^17.0.45", 54 | "@types/supertest": "^2.0.12", 55 | "@types/uuid": "^9.0.0", 56 | "@typescript-eslint/eslint-plugin": "^5.51.0", 57 | "@typescript-eslint/parser": "^5.51.0", 58 | "eslint": "^8.33.0", 59 | "eslint-plugin-prettier": "^4.2.1", 60 | "jest": "^29.3.1", 61 | "prettier": "^2.8.3", 62 | "supertest": "^6.3.1", 63 | "typescript": "^4.9.5" 64 | }, 65 | "engines": { 66 | "node": ">=18.0.0" 67 | }, 68 | "packageManager": "yarn@4.3.1" 69 | } 70 | -------------------------------------------------------------------------------- /src/accounts/password.js: -------------------------------------------------------------------------------- 1 | import * as bcrypt from 'bcrypt'; 2 | import getAccountDb, { clearExpiredSessions } from '../account-db.js'; 3 | import * as uuid from 'uuid'; 4 | import finalConfig from '../load-config.js'; 5 | import { TOKEN_EXPIRATION_NEVER } from '../util/validate-user.js'; 6 | 7 | function isValidPassword(password) { 8 | return password != null && password !== ''; 9 | } 10 | 11 | function hashPassword(password) { 12 | return bcrypt.hashSync(password, 12); 13 | } 14 | 15 | export function bootstrapPassword(password) { 16 | if (!isValidPassword(password)) { 17 | return { error: 'invalid-password' }; 18 | } 19 | 20 | let hashed = hashPassword(password); 21 | let accountDb = getAccountDb(); 22 | accountDb.transaction(() => { 23 | accountDb.mutate('DELETE FROM auth WHERE method = ?', ['password']); 24 | accountDb.mutate('UPDATE auth SET active = 0'); 25 | accountDb.mutate( 26 | "INSERT INTO auth (method, display_name, extra_data, active) VALUES ('password', 'Password', ?, 1)", 27 | [hashed], 28 | ); 29 | }); 30 | 31 | return {}; 32 | } 33 | 34 | export function loginWithPassword(password) { 35 | if (!isValidPassword(password)) { 36 | return { error: 'invalid-password' }; 37 | } 38 | 39 | let accountDb = getAccountDb(); 40 | const { extra_data: passwordHash } = 41 | accountDb.first('SELECT extra_data FROM auth WHERE method = ?', [ 42 | 'password', 43 | ]) || {}; 44 | 45 | if (!passwordHash) { 46 | return { error: 'invalid-password' }; 47 | } 48 | 49 | let confirmed = bcrypt.compareSync(password, passwordHash); 50 | 51 | if (!confirmed) { 52 | return { error: 'invalid-password' }; 53 | } 54 | 55 | let sessionRow = accountDb.first( 56 | 'SELECT * FROM sessions WHERE auth_method = ?', 57 | ['password'], 58 | ); 59 | 60 | let token = sessionRow ? sessionRow.token : uuid.v4(); 61 | 62 | let { totalOfUsers } = accountDb.first( 63 | 'SELECT count(*) as totalOfUsers FROM users', 64 | ); 65 | let userId = null; 66 | if (totalOfUsers === 0) { 67 | userId = uuid.v4(); 68 | accountDb.mutate( 69 | 'INSERT INTO users (id, user_name, display_name, enabled, owner, role) VALUES (?, ?, ?, 1, 1, ?)', 70 | [userId, '', '', 'ADMIN'], 71 | ); 72 | } else { 73 | let { id: userIdFromDb } = accountDb.first( 74 | 'SELECT id FROM users WHERE user_name = ?', 75 | [''], 76 | ); 77 | 78 | userId = userIdFromDb; 79 | 80 | if (!userId) { 81 | return { error: 'user-not-found' }; 82 | } 83 | } 84 | 85 | let expiration = TOKEN_EXPIRATION_NEVER; 86 | if ( 87 | finalConfig.token_expiration != 'never' && 88 | finalConfig.token_expiration != 'openid-provider' && 89 | typeof finalConfig.token_expiration === 'number' 90 | ) { 91 | expiration = 92 | Math.floor(Date.now() / 1000) + finalConfig.token_expiration * 60; 93 | } 94 | 95 | if (!sessionRow) { 96 | accountDb.mutate( 97 | 'INSERT INTO sessions (token, expires_at, user_id, auth_method) VALUES (?, ?, ?, ?)', 98 | [token, expiration, userId, 'password'], 99 | ); 100 | } else { 101 | accountDb.mutate( 102 | 'UPDATE sessions SET user_id = ?, expires_at = ? WHERE token = ?', 103 | [userId, expiration, token], 104 | ); 105 | } 106 | 107 | clearExpiredSessions(); 108 | 109 | return { token }; 110 | } 111 | 112 | export function changePassword(newPassword) { 113 | let accountDb = getAccountDb(); 114 | 115 | if (!isValidPassword(newPassword)) { 116 | return { error: 'invalid-password' }; 117 | } 118 | 119 | let hashed = hashPassword(newPassword); 120 | accountDb.mutate("UPDATE auth SET extra_data = ? WHERE method = 'password'", [ 121 | hashed, 122 | ]); 123 | return {}; 124 | } 125 | -------------------------------------------------------------------------------- /src/app-account.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { 3 | errorMiddleware, 4 | requestLoggerMiddleware, 5 | } from './util/middlewares.js'; 6 | import validateSession, { validateAuthHeader } from './util/validate-user.js'; 7 | import { 8 | bootstrap, 9 | needsBootstrap, 10 | getLoginMethod, 11 | listLoginMethods, 12 | getUserInfo, 13 | getActiveLoginMethod, 14 | } from './account-db.js'; 15 | import { changePassword, loginWithPassword } from './accounts/password.js'; 16 | import { isValidRedirectUrl, loginWithOpenIdSetup } from './accounts/openid.js'; 17 | 18 | let app = express(); 19 | app.use(express.json()); 20 | app.use(express.urlencoded({ extended: true })); 21 | app.use(errorMiddleware); 22 | app.use(requestLoggerMiddleware); 23 | export { app as handlers }; 24 | 25 | // Non-authenticated endpoints: 26 | // 27 | // /needs-bootstrap 28 | // /boostrap (special endpoint for setting up the instance, cant call again) 29 | // /login 30 | 31 | app.get('/needs-bootstrap', (req, res) => { 32 | res.send({ 33 | status: 'ok', 34 | data: { 35 | bootstrapped: !needsBootstrap(), 36 | loginMethod: getLoginMethod(), 37 | availableLoginMethods: listLoginMethods(), 38 | multiuser: getActiveLoginMethod() === 'openid', 39 | }, 40 | }); 41 | }); 42 | 43 | app.post('/bootstrap', async (req, res) => { 44 | let boot = await bootstrap(req.body); 45 | 46 | if (boot?.error) { 47 | res.status(400).send({ status: 'error', reason: boot?.error }); 48 | return; 49 | } 50 | res.send({ status: 'ok', data: boot }); 51 | }); 52 | 53 | app.get('/login-methods', (req, res) => { 54 | let methods = listLoginMethods(); 55 | res.send({ status: 'ok', methods }); 56 | }); 57 | 58 | app.post('/login', async (req, res) => { 59 | let loginMethod = getLoginMethod(req); 60 | console.log('Logging in via ' + loginMethod); 61 | let tokenRes = null; 62 | switch (loginMethod) { 63 | case 'header': { 64 | let headerVal = req.get('x-actual-password') || ''; 65 | const obfuscated = 66 | '*'.repeat(headerVal.length) || 'No password provided.'; 67 | console.debug('HEADER VALUE: ' + obfuscated); 68 | if (headerVal == '') { 69 | res.send({ status: 'error', reason: 'invalid-header' }); 70 | return; 71 | } else { 72 | if (validateAuthHeader(req)) { 73 | tokenRes = loginWithPassword(headerVal); 74 | } else { 75 | res.send({ status: 'error', reason: 'proxy-not-trusted' }); 76 | return; 77 | } 78 | } 79 | break; 80 | } 81 | case 'openid': { 82 | if (!isValidRedirectUrl(req.body.return_url)) { 83 | res 84 | .status(400) 85 | .send({ status: 'error', reason: 'Invalid redirect URL' }); 86 | return; 87 | } 88 | 89 | let { error, url } = await loginWithOpenIdSetup(req.body.return_url); 90 | if (error) { 91 | res.status(400).send({ status: 'error', reason: error }); 92 | return; 93 | } 94 | res.send({ status: 'ok', data: { redirect_url: url } }); 95 | return; 96 | } 97 | 98 | default: 99 | tokenRes = loginWithPassword(req.body.password); 100 | break; 101 | } 102 | let { error, token } = tokenRes; 103 | 104 | if (error) { 105 | res.status(400).send({ status: 'error', reason: error }); 106 | return; 107 | } 108 | 109 | res.send({ status: 'ok', data: { token } }); 110 | }); 111 | 112 | app.post('/change-password', (req, res) => { 113 | let session = validateSession(req, res); 114 | if (!session) return; 115 | 116 | let { error } = changePassword(req.body.password); 117 | 118 | if (error) { 119 | res.status(400).send({ status: 'error', reason: error }); 120 | return; 121 | } 122 | 123 | res.send({ status: 'ok', data: {} }); 124 | }); 125 | 126 | app.get('/validate', (req, res) => { 127 | let session = validateSession(req, res); 128 | if (session) { 129 | const user = getUserInfo(session.user_id); 130 | if (!user) { 131 | res.status(400).send({ status: 'error', reason: 'User not found' }); 132 | return; 133 | } 134 | 135 | res.send({ 136 | status: 'ok', 137 | data: { 138 | validated: true, 139 | userName: user?.user_name, 140 | permission: user?.role, 141 | userId: session?.user_id, 142 | displayName: user?.display_name, 143 | loginMethod: session?.auth_method, 144 | }, 145 | }); 146 | } 147 | }); 148 | -------------------------------------------------------------------------------- /src/app-gocardless/bank-factory.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import { fileURLToPath, pathToFileURL } from 'node:url'; 4 | 5 | import IntegrationBank from './banks/integration-bank.js'; 6 | 7 | const dirname = path.resolve(fileURLToPath(import.meta.url), '..'); 8 | const banksDir = path.resolve(dirname, 'banks'); 9 | 10 | async function loadBanks() { 11 | const bankHandlers = fs 12 | .readdirSync(banksDir) 13 | .filter((filename) => filename.includes('_') && filename.endsWith('.js')); 14 | 15 | const imports = await Promise.all( 16 | bankHandlers.map((file) => { 17 | const fileUrlToBank = pathToFileURL(path.resolve(banksDir, file)); // pathToFileURL for ESM compatibility 18 | return import(fileUrlToBank.toString()).then( 19 | (handler) => handler.default, 20 | ); 21 | }), 22 | ); 23 | 24 | return imports; 25 | } 26 | 27 | export const banks = await loadBanks(); 28 | 29 | export default (institutionId) => 30 | banks.find((b) => b.institutionIds.includes(institutionId)) || 31 | IntegrationBank; 32 | 33 | export const BANKS_WITH_LIMITED_HISTORY = [ 34 | 'BANCA_AIDEXA_AIDXITMM', 35 | 'BANCA_PATRIMONI_SENVITT1', 36 | 'BANCA_SELLA_SELBIT2B', 37 | 'BANKINTER_BKBKESMM', 38 | 'BBVA_BBVAESMM', 39 | 'BRED_BREDFRPPXXX', 40 | 'CAIXA_GERAL_DEPOSITOS_CGDIPTPL', 41 | 'CAIXABANK_CAIXESBB', 42 | 'CARTALIS_CIMTITR1', 43 | 'CESKA_SPORITELNA_LONG_GIBACZPX', 44 | 'COOP_EKRDEE22', 45 | 'DKB_BYLADEM1', 46 | 'DOTS_HYEEIT22', 47 | 'FINECO_FEBIITM2XXX', 48 | 'FINECO_UK_FEBIITM2XXX', 49 | 'FORTUNEO_FTNOFRP1XXX', 50 | 'HYPE_BUSINESS_HYEEIT22', 51 | 'HYPE_HYEEIT22', 52 | 'ILLIMITY_ITTPIT2M', 53 | 'INDUSTRA_MULTLV2X', 54 | 'JEKYLL_JEYKLL002', 55 | 'LABORALKUTXA_CLPEES2M', 56 | 'LHV_LHVBEE22', 57 | 'LUMINOR_AGBLLT2X', 58 | 'LUMINOR_NDEAEE2X', 59 | 'LUMINOR_NDEALT2X', 60 | 'LUMINOR_NDEALV2X', 61 | 'LUMINOR_RIKOEE22', 62 | 'LUMINOR_RIKOLV2X', 63 | 'MEDICINOSBANK_MDBALT22XXX', 64 | 'NORDEA_NDEADKKK', 65 | 'N26_NTSBDEB1', 66 | 'OPYN_BITAITRRB2B', 67 | 'PAYTIPPER_PAYTITM1', 68 | 'REVOLUT_REVOLT21', 69 | 'SANTANDER_BSCHESMM', 70 | 'SANTANDER_DE_SCFBDE33', 71 | 'SEB_CBVILT2X', 72 | 'SEB_EEUHEE2X', 73 | 'SEB_UNLALV2X', 74 | 'SELLA_PERSONAL_CREDIT_SELBIT22', 75 | 'BANCOACTIVOBANK_ACTVPTPL', 76 | 'SMARTIKA_SELBIT22', 77 | 'SWEDBANK_HABAEE2X', 78 | 'SWEDBANK_HABALT22', 79 | 'SWEDBANK_HABALV22', 80 | 'SWEDBANK_SWEDSESS', 81 | 'TIM_HYEEIT22', 82 | 'TOT_SELBIT2B', 83 | 'VUB_BANKA_SUBASKBX', 84 | ]; 85 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/abanca_caglesmm.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { formatPayeeName } from '../../util/payee-name.js'; 4 | 5 | /** @type {import('./bank.interface.js').IBank} */ 6 | export default { 7 | ...Fallback, 8 | 9 | institutionIds: [ 10 | 'ABANCA_CAGLESMM', 11 | 'ABANCA_CAGLPTPL', 12 | 'ABANCA_CORP_CAGLPTPL', 13 | ], 14 | 15 | // Abanca transactions doesn't get the creditorName/debtorName properly 16 | normalizeTransaction(transaction, _booked) { 17 | transaction.creditorName = transaction.remittanceInformationStructured; 18 | transaction.debtorName = transaction.remittanceInformationStructured; 19 | 20 | return { 21 | ...transaction, 22 | payeeName: formatPayeeName(transaction), 23 | date: transaction.bookingDate || transaction.valueDate, 24 | }; 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/abnamro_abnanl2a.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { amountToInteger } from '../utils.js'; 4 | import { formatPayeeName } from '../../util/payee-name.js'; 5 | 6 | /** @type {import('./bank.interface.js').IBank} */ 7 | export default { 8 | ...Fallback, 9 | 10 | institutionIds: ['ABNAMRO_ABNANL2A'], 11 | 12 | normalizeTransaction(transaction, _booked) { 13 | // There is no remittanceInformationUnstructured, so we'll make it 14 | transaction.remittanceInformationUnstructured = 15 | transaction.remittanceInformationUnstructuredArray.join(', '); 16 | 17 | // Remove clutter to extract the payee from remittanceInformationUnstructured ... 18 | // ... when not otherwise provided. 19 | const payeeName = transaction.remittanceInformationUnstructuredArray 20 | .map((el) => el.match(/^(?:.*\*)?(.+),PAS\d+$/)) 21 | .find((match) => match)?.[1]; 22 | transaction.debtorName = transaction.debtorName || payeeName; 23 | transaction.creditorName = transaction.creditorName || payeeName; 24 | 25 | return { 26 | ...transaction, 27 | payeeName: formatPayeeName(transaction), 28 | date: transaction.valueDateTime.slice(0, 10), 29 | }; 30 | }, 31 | 32 | sortTransactions(transactions = []) { 33 | return transactions.sort( 34 | (a, b) => +new Date(b.valueDateTime) - +new Date(a.valueDateTime), 35 | ); 36 | }, 37 | 38 | calculateStartingBalance(sortedTransactions = [], balances = []) { 39 | if (sortedTransactions.length) { 40 | const oldestTransaction = 41 | sortedTransactions[sortedTransactions.length - 1]; 42 | const oldestKnownBalance = amountToInteger( 43 | oldestTransaction.balanceAfterTransaction.balanceAmount.amount, 44 | ); 45 | const oldestTransactionAmount = amountToInteger( 46 | oldestTransaction.transactionAmount.amount, 47 | ); 48 | 49 | return oldestKnownBalance - oldestTransactionAmount; 50 | } else { 51 | return amountToInteger( 52 | balances.find((balance) => 'interimBooked' === balance.balanceType) 53 | .balanceAmount.amount, 54 | ); 55 | } 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/american_express_aesudef1.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { amountToInteger } from '../utils.js'; 4 | import { formatPayeeName } from '../../util/payee-name.js'; 5 | 6 | /** @type {import('./bank.interface.js').IBank} */ 7 | export default { 8 | ...Fallback, 9 | 10 | institutionIds: ['AMERICAN_EXPRESS_AESUDEF1'], 11 | 12 | normalizeAccount(account) { 13 | return { 14 | ...Fallback.normalizeAccount(account), 15 | // The `iban` field for these American Express cards is actually a masked 16 | // version of the PAN. No IBAN is provided. 17 | mask: account.iban.slice(-5), 18 | iban: null, 19 | name: [account.details, `(${account.iban.slice(-5)})`].join(' '), 20 | official_name: account.details, 21 | }; 22 | }, 23 | 24 | normalizeTransaction(transaction, _booked) { 25 | return { 26 | ...transaction, 27 | payeeName: formatPayeeName(transaction), 28 | date: transaction.bookingDate, 29 | }; 30 | }, 31 | 32 | /** 33 | * For AMERICAN_EXPRESS_AESUDEF1 we don't know what balance was 34 | * after each transaction so we have to calculate it by getting 35 | * current balance from the account and subtract all the transactions 36 | * 37 | * As a current balance we use the non-standard `information` balance type 38 | * which is the only one provided for American Express. 39 | */ 40 | calculateStartingBalance(sortedTransactions = [], balances = []) { 41 | const currentBalance = balances.find( 42 | (balance) => 'information' === balance.balanceType.toString(), 43 | ); 44 | 45 | return sortedTransactions.reduce((total, trans) => { 46 | return total - amountToInteger(trans.transactionAmount.amount); 47 | }, amountToInteger(currentBalance.balanceAmount.amount)); 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/bancsabadell_bsabesbbb.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { formatPayeeName } from '../../util/payee-name.js'; 4 | 5 | /** @type {import('./bank.interface.js').IBank} */ 6 | export default { 7 | ...Fallback, 8 | 9 | institutionIds: ['BANCSABADELL_BSABESBB'], 10 | 11 | // Sabadell transactions don't get the creditorName/debtorName properly 12 | normalizeTransaction(transaction, _booked) { 13 | const amount = transaction.transactionAmount.amount; 14 | 15 | // The amount is negative for outgoing transactions, positive for incoming transactions. 16 | const isCreditorPayee = Number.parseFloat(amount) < 0; 17 | 18 | const payeeName = transaction.remittanceInformationUnstructuredArray 19 | .join(' ') 20 | .trim(); 21 | 22 | // The payee name is the creditor name for outgoing transactions and the debtor name for incoming transactions. 23 | const creditorName = isCreditorPayee ? payeeName : null; 24 | const debtorName = isCreditorPayee ? null : payeeName; 25 | 26 | transaction.creditorName = creditorName; 27 | transaction.debtorName = debtorName; 28 | 29 | return { 30 | ...transaction, 31 | payeeName: formatPayeeName(transaction), 32 | date: transaction.bookingDate || transaction.valueDate, 33 | }; 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/bank.interface.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DetailedAccountWithInstitution, 3 | NormalizedAccountDetails, 4 | } from '../gocardless.types.js'; 5 | import { Transaction, Balance } from '../gocardless-node.types.js'; 6 | 7 | export interface IBank { 8 | institutionIds: string[]; 9 | 10 | /** 11 | * Returns normalized object with required data for the frontend 12 | */ 13 | normalizeAccount: ( 14 | account: DetailedAccountWithInstitution, 15 | ) => NormalizedAccountDetails; 16 | 17 | /** 18 | * Returns a normalized transaction object 19 | * 20 | * The GoCardless integrations with different banks are very inconsistent in 21 | * what each of the different date fields actually mean, so this function is 22 | * expected to set a `date` field which corresponds to the expected 23 | * transaction date. 24 | */ 25 | normalizeTransaction: ( 26 | transaction: Transaction, 27 | booked: boolean, 28 | ) => (Transaction & { date?: string; payeeName: string }) | null; 29 | 30 | /** 31 | * Function sorts an array of transactions from newest to oldest 32 | */ 33 | sortTransactions: (transactions: T[]) => T[]; 34 | 35 | /** 36 | * Calculates account balance before which was before transactions provided in sortedTransactions param 37 | */ 38 | calculateStartingBalance: ( 39 | sortedTransactions: Transaction[], 40 | balances: Balance[], 41 | ) => number; 42 | } 43 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/bank_of_ireland_b365_bofiie2d.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | /** @type {import('./bank.interface.js').IBank} */ 4 | export default { 5 | ...Fallback, 6 | 7 | institutionIds: ['BANK_OF_IRELAND_B365_BOFIIE2D'], 8 | 9 | normalizeTransaction(transaction, booked) { 10 | transaction.remittanceInformationUnstructured = fixupPayee( 11 | transaction.remittanceInformationUnstructured, 12 | ); 13 | 14 | return Fallback.normalizeTransaction(transaction, booked); 15 | }, 16 | }; 17 | 18 | function fixupPayee(/** @type {string} */ payee) { 19 | let fixedPayee = payee; 20 | 21 | // remove all duplicate whitespace 22 | fixedPayee = fixedPayee.replace(/\s+/g, ' ').trim(); 23 | 24 | // remove date prefix 25 | fixedPayee = fixedPayee.replace(/^(POS)?(C)?[0-9]{1,2}\w{3}/, '').trim(); 26 | 27 | // remove direct debit postfix 28 | fixedPayee = fixedPayee.replace(/sepa dd$/i, '').trim(); 29 | 30 | // remove bank transfer prefix 31 | fixedPayee = fixedPayee.replace(/^365 online/i, '').trim(); 32 | 33 | // remove curve card prefix 34 | fixedPayee = fixedPayee.replace(/^CRV\*/, '').trim(); 35 | 36 | return fixedPayee; 37 | } 38 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/bankinter_bkbkesmm.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { formatPayeeName } from '../../util/payee-name.js'; 4 | 5 | /** @type {import('./bank.interface.js').IBank} */ 6 | export default { 7 | ...Fallback, 8 | 9 | institutionIds: ['BANKINTER_BKBKESMM'], 10 | 11 | normalizeTransaction(transaction, _booked) { 12 | transaction.remittanceInformationUnstructured = 13 | transaction.remittanceInformationUnstructured 14 | .replaceAll(/\/Txt\/(\w\|)?/gi, '') 15 | .replaceAll(';', ' '); 16 | 17 | transaction.debtorName = transaction.debtorName?.replaceAll(';', ' '); 18 | transaction.creditorName = 19 | transaction.creditorName?.replaceAll(';', ' ') ?? 20 | transaction.remittanceInformationUnstructured; 21 | 22 | return { 23 | ...transaction, 24 | payeeName: formatPayeeName(transaction), 25 | date: transaction.bookingDate || transaction.valueDate, 26 | }; 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/belfius_gkccbebb.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { formatPayeeName } from '../../util/payee-name.js'; 4 | 5 | /** @type {import('./bank.interface.js').IBank} */ 6 | export default { 7 | ...Fallback, 8 | 9 | institutionIds: ['BELFIUS_GKCCBEBB'], 10 | 11 | // The problem is that we have transaction with duplicated transaction ids. 12 | // This is not expected and the nordigen api has a work-around for some backs 13 | // They will set an internalTransactionId which is unique 14 | normalizeTransaction(transaction, _booked) { 15 | return { 16 | ...transaction, 17 | transactionId: transaction.internalTransactionId, 18 | payeeName: formatPayeeName(transaction), 19 | date: transaction.bookingDate || transaction.valueDate, 20 | }; 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/berliner_sparkasse_beladebexxx.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { amountToInteger } from '../utils.js'; 4 | import { formatPayeeName } from '../../util/payee-name.js'; 5 | 6 | /** @type {import('./bank.interface.js').IBank} */ 7 | export default { 8 | ...Fallback, 9 | 10 | institutionIds: ['BERLINER_SPARKASSE_BELADEBEXXX'], 11 | 12 | /** 13 | * Following the GoCardless documentation[0] we should prefer `bookingDate` 14 | * here, though some of their bank integrations uses the date field 15 | * differently from what's described in their documentation and so it's 16 | * sometimes necessary to use `valueDate` instead. 17 | * 18 | * [0]: https://nordigen.zendesk.com/hc/en-gb/articles/7899367372829-valueDate-and-bookingDate-for-transactions 19 | */ 20 | normalizeTransaction(transaction, _booked) { 21 | const date = 22 | transaction.bookingDate || 23 | transaction.bookingDateTime || 24 | transaction.valueDate || 25 | transaction.valueDateTime; 26 | 27 | // If we couldn't find a valid date field we filter out this transaction 28 | // and hope that we will import it again once the bank has processed the 29 | // transaction further. 30 | if (!date) { 31 | return null; 32 | } 33 | 34 | let remittanceInformationUnstructured; 35 | 36 | if (transaction.remittanceInformationUnstructured) { 37 | remittanceInformationUnstructured = 38 | transaction.remittanceInformationUnstructured; 39 | } else if (transaction.remittanceInformationStructured) { 40 | remittanceInformationUnstructured = 41 | transaction.remittanceInformationStructured; 42 | } else if (transaction.remittanceInformationStructuredArray?.length > 0) { 43 | remittanceInformationUnstructured = 44 | transaction.remittanceInformationStructuredArray?.join(' '); 45 | } 46 | 47 | if (transaction.additionalInformation) 48 | remittanceInformationUnstructured += 49 | ' ' + transaction.additionalInformation; 50 | 51 | const usefulCreditorName = 52 | transaction.ultimateCreditor || 53 | transaction.creditorName || 54 | transaction.debtorName; 55 | 56 | transaction.creditorName = usefulCreditorName; 57 | transaction.remittanceInformationUnstructured = 58 | remittanceInformationUnstructured; 59 | 60 | return { 61 | ...transaction, 62 | payeeName: formatPayeeName(transaction), 63 | date: transaction.bookingDate || transaction.valueDate, 64 | }; 65 | }, 66 | 67 | /** 68 | * For SANDBOXFINANCE_SFIN0000 we don't know what balance was 69 | * after each transaction so we have to calculate it by getting 70 | * current balance from the account and subtract all the transactions 71 | * 72 | * As a current balance we use `interimBooked` balance type because 73 | * it includes transaction placed during current day 74 | */ 75 | calculateStartingBalance(sortedTransactions = [], balances = []) { 76 | const currentBalance = balances.find( 77 | (balance) => 'interimAvailable' === balance.balanceType, 78 | ); 79 | 80 | return sortedTransactions.reduce((total, trans) => { 81 | return total - amountToInteger(trans.transactionAmount.amount); 82 | }, amountToInteger(currentBalance.balanceAmount.amount)); 83 | }, 84 | }; 85 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/bnp_be_gebabebb.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { formatPayeeName } from '../../util/payee-name.js'; 4 | 5 | /** @type {import('./bank.interface.js').IBank} */ 6 | export default { 7 | ...Fallback, 8 | 9 | institutionIds: [ 10 | 'FINTRO_BE_GEBABEBB', 11 | 'HELLO_BE_GEBABEBB', 12 | 'BNP_BE_GEBABEBB', 13 | ], 14 | 15 | /** BNP_BE_GEBABEBB provides a lot of useful information via the 'additionalField' 16 | * There does not seem to be a specification of this field, but the following information is contained in its subfields: 17 | * - for pending transactions: the 'atmPosName' 18 | * - for booked transactions: the 'narrative'. 19 | * This narrative subfield is most useful as it contains information required to identify the transaction, 20 | * especially in case of debit card or instant payment transactions. 21 | * Do note that the narrative subfield ALSO contains the remittance information if any. 22 | * The goal of the normalization is to place any relevant information of the additionalInformation 23 | * field in the remittanceInformationUnstructuredArray field. 24 | */ 25 | normalizeTransaction(transaction, _booked) { 26 | // Extract the creditor name to fill it in with information from the 27 | // additionalInformation field in case it's not yet defined. 28 | let creditorName = transaction.creditorName; 29 | 30 | if (transaction.additionalInformation) { 31 | let additionalInformationObject = {}; 32 | const additionalInfoRegex = /(, )?([^:]+): ((\[.*?\])|([^,]*))/g; 33 | let matches = 34 | transaction.additionalInformation.matchAll(additionalInfoRegex); 35 | if (matches) { 36 | let creditorNameFromNarrative; // Possible value for creditorName 37 | for (let match of matches) { 38 | let key = match[2].trim(); 39 | let value = (match[4] || match[5]).trim(); 40 | if (key === 'narrative') { 41 | // Set narrativeName to the first element in the "narrative" array. 42 | let first_value = value.matchAll(/'(.+?)'/g)?.next().value; 43 | creditorNameFromNarrative = first_value 44 | ? first_value[1].trim() 45 | : undefined; 46 | } 47 | // Remove square brackets and single quotes and commas 48 | value = value.replace(/[[\]',]/g, ''); 49 | additionalInformationObject[key] = value; 50 | } 51 | // Keep existing unstructuredArray and add atmPosName and narrative 52 | transaction.remittanceInformationUnstructuredArray = [ 53 | transaction.remittanceInformationUnstructuredArray ?? '', 54 | additionalInformationObject?.atmPosName ?? '', 55 | additionalInformationObject?.narrative ?? '', 56 | ].filter(Boolean); 57 | 58 | // If the creditor name doesn't exist in the original transactions, 59 | // set it to the atmPosName or narrativeName if they exist; otherwise 60 | // leave empty and let the default rules handle it. 61 | creditorName = 62 | creditorName ?? 63 | additionalInformationObject?.atmPosName ?? 64 | creditorNameFromNarrative ?? 65 | null; 66 | } 67 | } 68 | 69 | transaction.creditorName = creditorName; 70 | 71 | return { 72 | ...transaction, 73 | payeeName: formatPayeeName(transaction), 74 | date: transaction.valueDate || transaction.bookingDate, 75 | }; 76 | }, 77 | }; 78 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/cbc_cregbebb.js: -------------------------------------------------------------------------------- 1 | import { extractPayeeNameFromRemittanceInfo } from './util/extract-payeeName-from-remittanceInfo.js'; 2 | import Fallback from './integration-bank.js'; 3 | 4 | /** @type {import('./bank.interface.js').IBank} */ 5 | export default { 6 | ...Fallback, 7 | 8 | institutionIds: ['CBC_CREGBEBB'], 9 | 10 | /** 11 | * For negative amounts, the only payee information we have is returned in 12 | * remittanceInformationUnstructured. 13 | */ 14 | normalizeTransaction(transaction, _booked) { 15 | if (Number(transaction.transactionAmount.amount) > 0) { 16 | return { 17 | ...transaction, 18 | payeeName: 19 | transaction.debtorName || 20 | transaction.remittanceInformationUnstructured, 21 | date: transaction.bookingDate || transaction.valueDate, 22 | }; 23 | } 24 | 25 | return { 26 | ...transaction, 27 | payeeName: 28 | transaction.creditorName || 29 | extractPayeeNameFromRemittanceInfo( 30 | transaction.remittanceInformationUnstructured, 31 | ['Paiement', 'Domiciliation', 'Transfert', 'Ordre permanent'], 32 | ), 33 | date: transaction.bookingDate || transaction.valueDate, 34 | }; 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/commerzbank_cobadeff.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | import { formatPayeeName } from '../../util/payee-name.js'; 3 | 4 | /** @type {import('./bank.interface.js').IBank} */ 5 | export default { 6 | ...Fallback, 7 | 8 | institutionIds: ['COMMERZBANK_COBADEFF'], 9 | 10 | normalizeTransaction(transaction, _booked) { 11 | // remittanceInformationUnstructured is limited to 140 chars thus ... 12 | // ... missing information form remittanceInformationUnstructuredArray ... 13 | // ... so we recreate it. 14 | transaction.remittanceInformationUnstructured = 15 | transaction.remittanceInformationUnstructuredArray.join(' '); 16 | 17 | // The limitations of remittanceInformationUnstructuredArray ... 18 | // ... can result in split keywords. We fix these. Other ... 19 | // ... splits will need to be fixed by user with rules. 20 | const keywords = [ 21 | 'End-to-End-Ref.:', 22 | 'Mandatsref:', 23 | 'Gläubiger-ID:', 24 | 'SEPA-BASISLASTSCHRIFT', 25 | 'Kartenzahlung', 26 | 'Dauerauftrag', 27 | ]; 28 | keywords.forEach((keyword) => { 29 | transaction.remittanceInformationUnstructured = 30 | transaction.remittanceInformationUnstructured.replace( 31 | // There can be spaces in keywords 32 | RegExp(keyword.split('').join('\\s*'), 'gi'), 33 | ', ' + keyword + ' ', 34 | ); 35 | }); 36 | 37 | // Clean up remittanceInformation, deduplicate payee (removing slashes ... 38 | // ... that are added to the remittanceInformation field), and ... 39 | // ... remove clutter like "End-to-End-Ref.: NOTPROVIDED" 40 | const payee = transaction.creditorName || transaction.debtorName || ''; 41 | transaction.remittanceInformationUnstructured = 42 | transaction.remittanceInformationUnstructured 43 | .replace(/\s*(,)?\s+/g, '$1 ') 44 | .replace(RegExp(payee.split(' ').join('(/*| )'), 'gi'), ' ') 45 | .replace(', End-to-End-Ref.: NOTPROVIDED', '') 46 | .trim(); 47 | 48 | return { 49 | ...transaction, 50 | payeeName: formatPayeeName(transaction), 51 | date: transaction.bookingDate, 52 | }; 53 | }, 54 | }; 55 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/danskebank_dabno22.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { amountToInteger } from '../utils.js'; 4 | import { formatPayeeName } from '../../util/payee-name.js'; 5 | 6 | /** @type {import('./bank.interface.js').IBank} */ 7 | export default { 8 | ...Fallback, 9 | 10 | institutionIds: ['DANSKEBANK_DABANO22'], 11 | 12 | normalizeTransaction(transaction, _booked) { 13 | /** 14 | * Danske Bank appends the EndToEndID: NOTPROVIDED to 15 | * remittanceInformationUnstructured, cluttering the data. 16 | * 17 | * We clean thais up by removing any instances of this string from all transactions. 18 | * 19 | */ 20 | transaction.remittanceInformationUnstructured = 21 | transaction.remittanceInformationUnstructured.replace( 22 | '\nEndToEndID: NOTPROVIDED', 23 | '', 24 | ); 25 | 26 | /** 27 | * The valueDate in transactions from Danske Bank is not the one expected, but rather the date 28 | * the funds are expected to be paid back for credit accounts. 29 | */ 30 | return { 31 | ...transaction, 32 | payeeName: formatPayeeName(transaction), 33 | date: transaction.bookingDate, 34 | }; 35 | }, 36 | 37 | calculateStartingBalance(sortedTransactions = [], balances = []) { 38 | const currentBalance = balances.find( 39 | (balance) => balance.balanceType === 'interimAvailable', 40 | ); 41 | 42 | return sortedTransactions.reduce((total, trans) => { 43 | return total - amountToInteger(trans.transactionAmount.amount); 44 | }, amountToInteger(currentBalance.balanceAmount.amount)); 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/direkt_heladef1822.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | /** @type {import('./bank.interface.js').IBank} */ 4 | export default { 5 | ...Fallback, 6 | 7 | institutionIds: ['DIREKT_HELADEF1822'], 8 | 9 | normalizeTransaction(transaction, booked) { 10 | transaction.remittanceInformationUnstructured = 11 | transaction.remittanceInformationUnstructured ?? 12 | transaction.remittanceInformationStructured; 13 | 14 | return Fallback.normalizeTransaction(transaction, booked); 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/easybank_bawaatww.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { formatPayeeName } from '../../util/payee-name.js'; 4 | import d from 'date-fns'; 5 | import { title } from '../../util/title/index.js'; 6 | 7 | /** @type {import('./bank.interface.js').IBank} */ 8 | export default { 9 | ...Fallback, 10 | 11 | institutionIds: ['EASYBANK_BAWAATWW'], 12 | 13 | // If date is same, sort by transactionId 14 | sortTransactions: (transactions = []) => 15 | transactions.sort((a, b) => { 16 | const diff = 17 | +new Date(b.valueDate || b.bookingDate) - 18 | +new Date(a.valueDate || a.bookingDate); 19 | if (diff != 0) return diff; 20 | return parseInt(b.transactionId) - parseInt(a.transactionId); 21 | }), 22 | 23 | normalizeTransaction(transaction, _booked) { 24 | const date = transaction.bookingDate || transaction.valueDate; 25 | 26 | // If we couldn't find a valid date field we filter out this transaction 27 | // and hope that we will import it again once the bank has processed the 28 | // transaction further. 29 | if (!date) { 30 | return null; 31 | } 32 | 33 | let payeeName = formatPayeeName(transaction); 34 | if (!payeeName) payeeName = extractPayeeName(transaction); 35 | 36 | return { 37 | ...transaction, 38 | payeeName: payeeName, 39 | date: d.format(d.parseISO(date), 'yyyy-MM-dd'), 40 | }; 41 | }, 42 | }; 43 | 44 | /** 45 | * Extracts the payee name from the remittanceInformationStructured 46 | * @param {import('../gocardless-node.types.js').Transaction} transaction 47 | */ 48 | function extractPayeeName(transaction) { 49 | const structured = transaction.remittanceInformationStructured; 50 | // The payee name is betweeen the transaction timestamp (11.07. 11:36) and the location, that starts with \\ 51 | const regex = /\d{2}\.\d{2}\. \d{2}:\d{2}(.*)\\\\/; 52 | const matches = structured.match(regex); 53 | if (matches && matches.length > 1 && matches[1]) { 54 | return title(matches[1]); 55 | } else { 56 | // As a fallback if still no payee is found, the whole information is used 57 | return structured; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/entercard_swednokk.js: -------------------------------------------------------------------------------- 1 | import * as d from 'date-fns'; 2 | 3 | import Fallback from './integration-bank.js'; 4 | 5 | import { amountToInteger } from '../utils.js'; 6 | import { formatPayeeName } from '../../util/payee-name.js'; 7 | 8 | /** @type {import('./bank.interface.js').IBank} */ 9 | export default { 10 | ...Fallback, 11 | 12 | institutionIds: ['ENTERCARD_SWEDNOKK'], 13 | 14 | normalizeTransaction(transaction, _booked) { 15 | // GoCardless's Entercard integration returns forex transactions with the 16 | // foreign amount in `transactionAmount`, but at least the amount actually 17 | // billed to the account is now available in 18 | // `remittanceInformationUnstructured`. 19 | const remittanceInformationUnstructured = 20 | transaction.remittanceInformationUnstructured; 21 | if (remittanceInformationUnstructured.startsWith('billingAmount: ')) { 22 | transaction.transactionAmount = { 23 | amount: remittanceInformationUnstructured.substring(15), 24 | currency: 'SEK', 25 | }; 26 | } 27 | 28 | return { 29 | ...transaction, 30 | payeeName: formatPayeeName(transaction), 31 | date: d.format(d.parseISO(transaction.valueDate), 'yyyy-MM-dd'), 32 | }; 33 | }, 34 | 35 | calculateStartingBalance(sortedTransactions = [], balances = []) { 36 | return sortedTransactions.reduce((total, trans) => { 37 | return total - amountToInteger(trans.transactionAmount.amount); 38 | }, amountToInteger(balances[0]?.balanceAmount?.amount || 0)); 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/fortuneo_ftnofrp1xxx.js: -------------------------------------------------------------------------------- 1 | import { formatPayeeName } from '../../util/payee-name.js'; 2 | import Fallback from './integration-bank.js'; 3 | import * as d from 'date-fns'; 4 | 5 | /** @type {import('./bank.interface.js').IBank} */ 6 | export default { 7 | ...Fallback, 8 | 9 | institutionIds: ['FORTUNEO_FTNOFRP1XXX'], 10 | 11 | normalizeTransaction(transaction, _booked) { 12 | const date = 13 | transaction.bookingDate || 14 | transaction.bookingDateTime || 15 | transaction.valueDate || 16 | transaction.valueDateTime; 17 | // If we couldn't find a valid date field we filter out this transaction 18 | // and hope that we will import it again once the bank has processed the 19 | // transaction further. 20 | if (!date) { 21 | return null; 22 | } 23 | 24 | // Most of the information from the transaction is in the remittanceInformationUnstructuredArray field. 25 | // We extract the creditor and debtor names from this field. 26 | // The remittanceInformationUnstructuredArray field usually contain keywords like "Vir" for 27 | // bank transfers or "Carte 03/06" for card payments, as well as the date. 28 | // We remove these keywords to get a cleaner payee name. 29 | const keywordsToRemove = [ 30 | 'VIR INST', 31 | 'VIR', 32 | 'PRLV', 33 | 'ANN CARTE', 34 | 'CARTE \\d{2}\\/\\d{2}', 35 | ]; 36 | 37 | const details = 38 | transaction.remittanceInformationUnstructuredArray.join(' '); 39 | const amount = transaction.transactionAmount.amount; 40 | 41 | const regex = new RegExp(keywordsToRemove.join('|'), 'g'); 42 | const payeeName = details.replace(regex, '').trim(); 43 | 44 | // The amount is negative for outgoing transactions, positive for incoming transactions. 45 | const isCreditorPayee = parseFloat(amount) < 0; 46 | 47 | // The payee name is the creditor name for outgoing transactions and the debtor name for incoming transactions. 48 | const creditorName = isCreditorPayee ? payeeName : null; 49 | const debtorName = isCreditorPayee ? null : payeeName; 50 | 51 | transaction.creditorName = creditorName; 52 | transaction.debtorName = debtorName; 53 | 54 | return { 55 | ...transaction, 56 | payeeName: formatPayeeName(transaction), 57 | date: d.format(d.parseISO(date), 'yyyy-MM-dd'), 58 | }; 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/hype_hyeeit22.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { formatPayeeName } from '../../util/payee-name.js'; 4 | 5 | /** @type {import('./bank.interface.js').IBank} */ 6 | export default { 7 | ...Fallback, 8 | 9 | institutionIds: ['HYPE_HYEEIT22'], 10 | 11 | normalizeTransaction(transaction, _booked) { 12 | /** Online card payments - identified by "crd" transaction code 13 | * always start with PAGAMENTO PRESSO + 14 | */ 15 | if (transaction.proprietaryBankTransactionCode == 'crd') { 16 | // remove PAGAMENTO PRESSO and set payee name 17 | transaction.debtorName = 18 | transaction.remittanceInformationUnstructured?.slice( 19 | 'PAGAMENTO PRESSO '.length, 20 | ); 21 | } 22 | /** 23 | * In-app money transfers (p2p) and bank transfers (bon) have remittance info structure like 24 | * DENARO (INVIATO/RICEVUTO) (A/DA) {payee_name} - {payment_info} (p2p) 25 | * HAI (INVIATO/RICEVUTO) UN BONIFICO (A/DA) {payee_name} - {payment_info} (bon) 26 | */ 27 | if ( 28 | transaction.proprietaryBankTransactionCode == 'p2p' || 29 | transaction.proprietaryBankTransactionCode == 'bon' 30 | ) { 31 | // keep only {payment_info} portion of remittance info 32 | // NOTE: if {payee_name} contains dashes (unlikely / impossible?), this probably gets bugged! 33 | let infoIdx = 34 | transaction.remittanceInformationUnstructured.indexOf(' - ') + 3; 35 | transaction.remittanceInformationUnstructured = 36 | infoIdx == -1 37 | ? transaction.remittanceInformationUnstructured 38 | : transaction.remittanceInformationUnstructured.slice(infoIdx).trim(); 39 | } 40 | /** 41 | * CONVERT ESCAPED UNICODE TO CODEPOINTS 42 | * p2p payments allow user to write arbitrary unicode strings as messages 43 | * gocardless reports unicode codepoints as \Uxxxx 44 | * so it groups them in 4bytes bundles 45 | * the code below assumes this is always the case 46 | */ 47 | if (transaction.proprietaryBankTransactionCode == 'p2p') { 48 | let str = transaction.remittanceInformationUnstructured; 49 | let idx = str.indexOf('\\U'); 50 | let start_idx = idx; 51 | let codepoints = []; 52 | while (idx !== -1) { 53 | codepoints.push(parseInt(str.slice(idx + 2, idx + 6), 16)); 54 | let next_idx = str.indexOf('\\U', idx + 6); 55 | if (next_idx == idx + 6) { 56 | idx = next_idx; 57 | continue; 58 | } 59 | str = 60 | str.slice(0, start_idx) + 61 | String.fromCodePoint(...codepoints) + 62 | str.slice(idx + 6); 63 | codepoints = []; 64 | idx = str.indexOf('\\U'); // slight inefficiency? 65 | start_idx = idx; 66 | } 67 | transaction.remittanceInformationUnstructured = str; 68 | } 69 | return { 70 | ...transaction, 71 | payeeName: formatPayeeName(transaction), 72 | date: transaction.valueDate || transaction.bookingDate, 73 | }; 74 | }, 75 | }; 76 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/ing_ingbrobu.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | /** @type {import('./bank.interface.js').IBank} */ 4 | export default { 5 | ...Fallback, 6 | 7 | institutionIds: ['ING_INGBROBU'], 8 | 9 | normalizeTransaction(transaction, booked) { 10 | //Merchant transactions all have the same transactionId of 'NOTPROVIDED'. 11 | //For booked transactions, this can be set to the internalTransactionId 12 | //For pending transactions, this needs to be removed for them to show up in Actual 13 | 14 | //For deduplication to work better, payeeName needs to be standardized 15 | //and converted from a pending transaction form ("payeeName":"Card no: xxxxxxxxxxxx1111"') to a booked transaction form ("payeeName":"Card no: Xxxx Xxxx Xxxx 1111") 16 | if (transaction.transactionId === 'NOTPROVIDED') { 17 | //Some corner case transactions only have the `proprietaryBankTransactionCode` field, this need to be copied to `remittanceInformationUnstructured` 18 | if ( 19 | transaction.proprietaryBankTransactionCode && 20 | !transaction.remittanceInformationUnstructured 21 | ) { 22 | transaction.remittanceInformationUnstructured = 23 | transaction.proprietaryBankTransactionCode; 24 | } 25 | 26 | if (booked) { 27 | transaction.transactionId = transaction.internalTransactionId; 28 | if ( 29 | transaction.remittanceInformationUnstructured && 30 | transaction.remittanceInformationUnstructured 31 | .toLowerCase() 32 | .includes('card no:') 33 | ) { 34 | transaction.creditorName = 35 | transaction.remittanceInformationUnstructured.split(',')[0]; 36 | //Catch all case for other types of payees 37 | } else { 38 | transaction.creditorName = 39 | transaction.remittanceInformationUnstructured; 40 | } 41 | } else { 42 | transaction.transactionId = null; 43 | 44 | if ( 45 | transaction.remittanceInformationUnstructured && 46 | transaction.remittanceInformationUnstructured 47 | .toLowerCase() 48 | .includes('card no:') 49 | ) { 50 | transaction.creditorName = 51 | transaction.remittanceInformationUnstructured.replace( 52 | /x{4}/g, 53 | 'Xxxx ', 54 | ); 55 | //Catch all case for other types of payees 56 | } else { 57 | transaction.creditorName = 58 | transaction.remittanceInformationUnstructured; 59 | } 60 | //Remove remittanceInformationUnstructured from pending transactions, so the `notes` field remains empty (there is no merchant information) 61 | //Once booked, the right `notes` (containing the merchant) will be populated 62 | transaction.remittanceInformationUnstructured = null; 63 | } 64 | } 65 | 66 | return Fallback.normalizeTransaction(transaction, booked); 67 | }, 68 | }; 69 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/ing_ingddeff.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { amountToInteger } from '../utils.js'; 4 | import { formatPayeeName } from '../../util/payee-name.js'; 5 | 6 | /** @type {import('./bank.interface.js').IBank} */ 7 | export default { 8 | ...Fallback, 9 | 10 | institutionIds: ['ING_INGDDEFF'], 11 | 12 | normalizeTransaction(transaction, _booked) { 13 | const remittanceInformationMatch = /remittanceinformation:(.*)$/.exec( 14 | transaction.remittanceInformationUnstructured, 15 | ); 16 | 17 | transaction.remittanceInformationUnstructured = remittanceInformationMatch 18 | ? remittanceInformationMatch[1] 19 | : transaction.remittanceInformationUnstructured; 20 | 21 | return { 22 | ...transaction, 23 | payeeName: formatPayeeName(transaction), 24 | date: transaction.bookingDate || transaction.valueDate, 25 | }; 26 | }, 27 | 28 | sortTransactions(transactions = []) { 29 | return transactions.sort((a, b) => { 30 | const diff = 31 | +new Date(b.valueDate || b.bookingDate) - 32 | +new Date(a.valueDate || a.bookingDate); 33 | if (diff) return diff; 34 | const idA = parseInt(a.transactionId); 35 | const idB = parseInt(b.transactionId); 36 | if (!isNaN(idA) && !isNaN(idB)) return idB - idA; 37 | return 0; 38 | }); 39 | }, 40 | 41 | calculateStartingBalance(sortedTransactions = [], balances = []) { 42 | const currentBalance = balances.find( 43 | (balance) => 'interimBooked' === balance.balanceType, 44 | ); 45 | 46 | return sortedTransactions.reduce((total, trans) => { 47 | return total - amountToInteger(trans.transactionAmount.amount); 48 | }, amountToInteger(currentBalance.balanceAmount.amount)); 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/ing_pl_ingbplpw.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { amountToInteger } from '../utils.js'; 4 | import { formatPayeeName } from '../../util/payee-name.js'; 5 | 6 | /** @type {import('./bank.interface.js').IBank} */ 7 | export default { 8 | ...Fallback, 9 | 10 | institutionIds: ['ING_PL_INGBPLPW'], 11 | 12 | normalizeTransaction(transaction, _booked) { 13 | return { 14 | ...transaction, 15 | payeeName: formatPayeeName(transaction), 16 | date: transaction.valueDate ?? transaction.bookingDate, 17 | }; 18 | }, 19 | 20 | sortTransactions(transactions = []) { 21 | return transactions.sort((a, b) => { 22 | return ( 23 | Number(b.transactionId.substr(2)) - Number(a.transactionId.substr(2)) 24 | ); 25 | }); 26 | }, 27 | 28 | calculateStartingBalance(sortedTransactions = [], balances = []) { 29 | if (sortedTransactions.length) { 30 | const oldestTransaction = 31 | sortedTransactions[sortedTransactions.length - 1]; 32 | const oldestKnownBalance = amountToInteger( 33 | oldestTransaction.balanceAfterTransaction.balanceAmount.amount, 34 | ); 35 | const oldestTransactionAmount = amountToInteger( 36 | oldestTransaction.transactionAmount.amount, 37 | ); 38 | 39 | return oldestKnownBalance - oldestTransactionAmount; 40 | } else { 41 | return amountToInteger( 42 | balances.find((balance) => 'interimBooked' === balance.balanceType) 43 | .balanceAmount.amount, 44 | ); 45 | } 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/integration-bank.js: -------------------------------------------------------------------------------- 1 | import * as d from 'date-fns'; 2 | import { 3 | amountToInteger, 4 | printIban, 5 | sortByBookingDateOrValueDate, 6 | } from '../utils.js'; 7 | import { formatPayeeName } from '../../util/payee-name.js'; 8 | 9 | const SORTED_BALANCE_TYPE_LIST = [ 10 | 'closingBooked', 11 | 'expected', 12 | 'forwardAvailable', 13 | 'interimAvailable', 14 | 'interimBooked', 15 | 'nonInvoiced', 16 | 'openingBooked', 17 | ]; 18 | 19 | /** @type {import('./bank.interface.js').IBank} */ 20 | export default { 21 | institutionIds: ['IntegrationBank'], 22 | 23 | normalizeAccount(account) { 24 | console.debug( 25 | 'Available account properties for new institution integration', 26 | { account: JSON.stringify(account) }, 27 | ); 28 | 29 | return { 30 | account_id: account.id, 31 | institution: account.institution, 32 | mask: (account?.iban || '0000').slice(-4), 33 | iban: account?.iban || null, 34 | name: [ 35 | account.name ?? account.displayName ?? account.product, 36 | printIban(account), 37 | account.currency, 38 | ] 39 | .filter(Boolean) 40 | .join(' '), 41 | official_name: account.product ?? `integration-${account.institution_id}`, 42 | type: 'checking', 43 | }; 44 | }, 45 | 46 | normalizeTransaction(transaction, _booked) { 47 | const date = 48 | transaction.bookingDate || 49 | transaction.bookingDateTime || 50 | transaction.valueDate || 51 | transaction.valueDateTime; 52 | // If we couldn't find a valid date field we filter out this transaction 53 | // and hope that we will import it again once the bank has processed the 54 | // transaction further. 55 | if (!date) { 56 | return null; 57 | } 58 | return { 59 | ...transaction, 60 | payeeName: formatPayeeName(transaction), 61 | date: d.format(d.parseISO(date), 'yyyy-MM-dd'), 62 | }; 63 | }, 64 | 65 | sortTransactions(transactions = []) { 66 | console.debug( 67 | 'Available (first 10) transactions properties for new integration of institution in sortTransactions function', 68 | { top10Transactions: JSON.stringify(transactions.slice(0, 10)) }, 69 | ); 70 | return sortByBookingDateOrValueDate(transactions); 71 | }, 72 | 73 | calculateStartingBalance(sortedTransactions = [], balances = []) { 74 | console.debug( 75 | 'Available (first 10) transactions properties for new integration of institution in calculateStartingBalance function', 76 | { 77 | balances: JSON.stringify(balances), 78 | top10SortedTransactions: JSON.stringify( 79 | sortedTransactions.slice(0, 10), 80 | ), 81 | }, 82 | ); 83 | 84 | const currentBalance = balances 85 | .filter((item) => SORTED_BALANCE_TYPE_LIST.includes(item.balanceType)) 86 | .sort( 87 | (a, b) => 88 | SORTED_BALANCE_TYPE_LIST.indexOf(a.balanceType) - 89 | SORTED_BALANCE_TYPE_LIST.indexOf(b.balanceType), 90 | )[0]; 91 | return sortedTransactions.reduce((total, trans) => { 92 | return total - amountToInteger(trans.transactionAmount.amount); 93 | }, amountToInteger(currentBalance?.balanceAmount?.amount || 0)); 94 | }, 95 | }; 96 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/isybank_itbbitmm.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | /** @type {import('./bank.interface.js').IBank} */ 4 | export default { 5 | ...Fallback, 6 | 7 | institutionIds: ['ISYBANK_ITBBITMM'], 8 | 9 | // It has been reported that valueDate is more accurate than booking date 10 | // when it is provided 11 | normalizeTransaction(transaction, booked) { 12 | transaction.bookingDate = transaction.valueDate ?? transaction.bookingDate; 13 | 14 | return Fallback.normalizeTransaction(transaction, booked); 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/kbc_kredbebb.js: -------------------------------------------------------------------------------- 1 | import { extractPayeeNameFromRemittanceInfo } from './util/extract-payeeName-from-remittanceInfo.js'; 2 | import Fallback from './integration-bank.js'; 3 | 4 | /** @type {import('./bank.interface.js').IBank} */ 5 | export default { 6 | ...Fallback, 7 | 8 | institutionIds: ['KBC_KREDBEBB'], 9 | 10 | /** 11 | * For negative amounts, the only payee information we have is returned in 12 | * remittanceInformationUnstructured. 13 | */ 14 | normalizeTransaction(transaction, _booked) { 15 | if (Number(transaction.transactionAmount.amount) > 0) { 16 | return { 17 | ...transaction, 18 | payeeName: 19 | transaction.debtorName || 20 | transaction.remittanceInformationUnstructured || 21 | 'undefined', 22 | date: transaction.bookingDate || transaction.valueDate, 23 | }; 24 | } 25 | 26 | return { 27 | ...transaction, 28 | payeeName: 29 | transaction.creditorName || 30 | extractPayeeNameFromRemittanceInfo( 31 | transaction.remittanceInformationUnstructured, 32 | ['Betaling met', 'Domiciliëring', 'Overschrijving'], 33 | ), 34 | date: transaction.bookingDate || transaction.valueDate, 35 | }; 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/lhv-lhvbee22.js: -------------------------------------------------------------------------------- 1 | import d from 'date-fns'; 2 | 3 | import Fallback from './integration-bank.js'; 4 | 5 | /** @type {import('./bank.interface.js').IBank} */ 6 | export default { 7 | ...Fallback, 8 | 9 | institutionIds: ['LHV_LHVBEE22'], 10 | 11 | normalizeTransaction(transaction, booked) { 12 | // extract bookingDate and creditorName for card transactions, e.g. 13 | // (..1234) 2025-01-02 09:32 CrustumOU\Poordi 3\Tallinn\10156 ESTEST 14 | // bookingDate: 2025-01-02 15 | // creditorName: CrustumOU 16 | const cardTxRegex = 17 | /^\(\.\.(\d{4})\) (\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}) (.+)$/g; 18 | const cardTxMatch = cardTxRegex.exec( 19 | transaction?.remittanceInformationUnstructured, 20 | ); 21 | 22 | if (cardTxMatch) { 23 | const extractedDate = d.parse(cardTxMatch[2], 'yyyy-MM-dd', new Date()); 24 | 25 | transaction = { 26 | ...transaction, 27 | creditorName: cardTxMatch[4].split('\\')[0].trim(), 28 | }; 29 | 30 | if (booked && d.isValid(extractedDate)) { 31 | transaction = { 32 | ...transaction, 33 | bookingDate: d.format(extractedDate, 'yyyy-MM-dd'), 34 | }; 35 | } 36 | } 37 | 38 | return Fallback.normalizeTransaction(transaction, booked); 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/mbank_retail_brexplpw.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { amountToInteger } from '../utils.js'; 4 | import { formatPayeeName } from '../../util/payee-name.js'; 5 | 6 | /** @type {import('./bank.interface.js').IBank} */ 7 | export default { 8 | ...Fallback, 9 | 10 | institutionIds: ['MBANK_RETAIL_BREXPLPW'], 11 | 12 | normalizeTransaction(transaction, _booked) { 13 | return { 14 | ...transaction, 15 | payeeName: formatPayeeName(transaction), 16 | date: transaction.bookingDate || transaction.valueDate, 17 | }; 18 | }, 19 | 20 | sortTransactions(transactions = []) { 21 | return transactions.sort( 22 | (a, b) => Number(b.transactionId) - Number(a.transactionId), 23 | ); 24 | }, 25 | 26 | /** 27 | * For MBANK_RETAIL_BREXPLPW we don't know what balance was 28 | * after each transaction so we have to calculate it by getting 29 | * current balance from the account and subtract all the transactions 30 | * 31 | * As a current balance we use `interimBooked` balance type because 32 | * it includes transaction placed during current day 33 | */ 34 | calculateStartingBalance(sortedTransactions = [], balances = []) { 35 | const currentBalance = balances.find( 36 | (balance) => 'interimBooked' === balance.balanceType, 37 | ); 38 | 39 | return sortedTransactions.reduce((total, trans) => { 40 | return total - amountToInteger(trans.transactionAmount.amount); 41 | }, amountToInteger(currentBalance.balanceAmount.amount)); 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/nationwide_naiagb21.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | /** @type {import('./bank.interface.js').IBank} */ 4 | export default { 5 | ...Fallback, 6 | 7 | institutionIds: ['NATIONWIDE_NAIAGB21'], 8 | 9 | normalizeTransaction(transaction, booked) { 10 | // Nationwide can sometimes return pending transactions with a date 11 | // representing the latest a transaction could be booked. This stops 12 | // actual's deduplication logic from working as it only checks 7 days 13 | // ahead/behind and the transactionID from Nationwide changes when a 14 | // transaction is booked 15 | if (!booked) { 16 | const useDate = new Date( 17 | Math.min( 18 | new Date(transaction.bookingDate).getTime(), 19 | new Date().getTime(), 20 | ), 21 | ); 22 | transaction.bookingDate = useDate.toISOString().slice(0, 10); 23 | } 24 | 25 | // Nationwide also occasionally returns erroneous transaction_ids 26 | // that are malformed and can even change after import. This will ignore 27 | // these ids and unset them. When a correct ID is returned then it will 28 | // update via the deduplication logic 29 | const debitCreditRegex = /^00(DEB|CRED)IT.+$/; 30 | const validLengths = [ 31 | 40, // Nationwide credit cards 32 | 32, // Nationwide current accounts 33 | ]; 34 | 35 | if ( 36 | transaction.transactionId?.match(debitCreditRegex) || 37 | !validLengths.includes(transaction.transactionId?.length) 38 | ) { 39 | transaction.transactionId = null; 40 | } 41 | 42 | return Fallback.normalizeTransaction(transaction, booked); 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/nbg_ethngraaxxx.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { amountToInteger } from '../utils.js'; 4 | import { formatPayeeName } from '../../util/payee-name.js'; 5 | 6 | /** @type {import('./bank.interface.js').IBank} */ 7 | export default { 8 | ...Fallback, 9 | 10 | institutionIds: ['NBG_ETHNGRAAXXX'], 11 | 12 | /** 13 | * Fixes for the pending transactions: 14 | * - Corrects amount to negative (nbg erroneously omits the minus sign in pending transactions) 15 | * - Removes prefix 'ΑΓΟΡΑ' from remittance information to align with the booked transaction (necessary for fuzzy matching to work) 16 | */ 17 | normalizeTransaction(transaction, _booked) { 18 | if ( 19 | !transaction.transactionId && 20 | transaction.remittanceInformationUnstructured.startsWith('ΑΓΟΡΑ ') 21 | ) { 22 | transaction = { 23 | ...transaction, 24 | transactionAmount: { 25 | amount: '-' + transaction.transactionAmount.amount, 26 | currency: transaction.transactionAmount.currency, 27 | }, 28 | remittanceInformationUnstructured: 29 | transaction.remittanceInformationUnstructured.substring(6), 30 | }; 31 | } 32 | 33 | return { 34 | ...transaction, 35 | payeeName: formatPayeeName(transaction), 36 | date: transaction.bookingDate || transaction.valueDate, 37 | }; 38 | }, 39 | 40 | /** 41 | * For NBG_ETHNGRAAXXX we don't know what balance was 42 | * after each transaction so we have to calculate it by getting 43 | * current balance from the account and subtract all the transactions 44 | * 45 | * As a current balance we use `interimBooked` balance type because 46 | * it includes transaction placed during current day 47 | */ 48 | calculateStartingBalance(sortedTransactions = [], balances = []) { 49 | const currentBalance = balances.find( 50 | (balance) => 'interimAvailable' === balance.balanceType, 51 | ); 52 | 53 | return sortedTransactions.reduce((total, trans) => { 54 | return total - amountToInteger(trans.transactionAmount.amount); 55 | }, amountToInteger(currentBalance.balanceAmount.amount)); 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/norwegian_xx_norwnok1.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { amountToInteger } from '../utils.js'; 4 | import { formatPayeeName } from '../../util/payee-name.js'; 5 | 6 | /** @type {import('./bank.interface.js').IBank} */ 7 | export default { 8 | ...Fallback, 9 | 10 | institutionIds: [ 11 | 'NORWEGIAN_NO_NORWNOK1', 12 | 'NORWEGIAN_SE_NORWNOK1', 13 | 'NORWEGIAN_DE_NORWNOK1', 14 | 'NORWEGIAN_DK_NORWNOK1', 15 | 'NORWEGIAN_ES_NORWNOK1', 16 | 'NORWEGIAN_FI_NORWNOK1', 17 | ], 18 | 19 | normalizeTransaction(transaction, booked) { 20 | if (booked) { 21 | return { 22 | ...transaction, 23 | payeeName: formatPayeeName(transaction), 24 | date: transaction.bookingDate, 25 | }; 26 | } 27 | 28 | /** 29 | * For pending transactions there are two possibilities: 30 | * 31 | * - Either a `valueDate` was set, in which case it corresponds to when the 32 | * transaction actually occurred, or 33 | * - There is no date field, in which case we try to parse the correct date 34 | * out of the `remittanceInformationStructured` field. 35 | * 36 | * If neither case succeeds then we return `null` causing this transaction 37 | * to be filtered out for now, and hopefully we'll be able to import it 38 | * once the bank has processed it further. 39 | */ 40 | if (transaction.valueDate !== undefined) { 41 | return { 42 | ...transaction, 43 | payeeName: formatPayeeName(transaction), 44 | date: transaction.valueDate, 45 | }; 46 | } 47 | 48 | if (transaction.remittanceInformationStructured) { 49 | const remittanceInfoRegex = / (\d{4}-\d{2}-\d{2}) /; 50 | const matches = 51 | transaction.remittanceInformationStructured.match(remittanceInfoRegex); 52 | if (matches) { 53 | transaction.valueDate = matches[1]; 54 | return { 55 | ...transaction, 56 | payeeName: formatPayeeName(transaction), 57 | date: matches[1], 58 | }; 59 | } 60 | } 61 | 62 | return null; 63 | }, 64 | 65 | /** 66 | * For NORWEGIAN_XX_NORWNOK1 we don't know what balance was 67 | * after each transaction so we have to calculate it by getting 68 | * current balance from the account and subtract all the transactions 69 | * 70 | * As a current balance we use `expected` balance type because it 71 | * corresponds to the current running balance, whereas `interimAvailable` 72 | * holds the remaining credit limit. 73 | */ 74 | calculateStartingBalance(sortedTransactions = [], balances = []) { 75 | const currentBalance = balances.find( 76 | (balance) => 'expected' === balance.balanceType, 77 | ); 78 | 79 | return sortedTransactions.reduce((total, trans) => { 80 | return total - amountToInteger(trans.transactionAmount.amount); 81 | }, amountToInteger(currentBalance.balanceAmount.amount)); 82 | }, 83 | }; 84 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/revolut_revolt21.js: -------------------------------------------------------------------------------- 1 | import { formatPayeeName } from '../../util/payee-name.js'; 2 | import * as d from 'date-fns'; 3 | import Fallback from './integration-bank.js'; 4 | 5 | /** @type {import('./bank.interface.js').IBank} */ 6 | export default { 7 | ...Fallback, 8 | 9 | institutionIds: ['REVOLUT_REVOLT21'], 10 | 11 | normalizeTransaction(transaction, _booked) { 12 | if ( 13 | transaction.remittanceInformationUnstructuredArray[0].startsWith( 14 | 'Bizum payment from: ', 15 | ) 16 | ) { 17 | const date = 18 | transaction.bookingDate || 19 | transaction.bookingDateTime || 20 | transaction.valueDate || 21 | transaction.valueDateTime; 22 | 23 | return { 24 | ...transaction, 25 | payeeName: 26 | transaction.remittanceInformationUnstructuredArray[0].replace( 27 | 'Bizum payment from: ', 28 | '', 29 | ), 30 | remittanceInformationUnstructured: 31 | transaction.remittanceInformationUnstructuredArray[1], 32 | date: d.format(d.parseISO(date), 'yyyy-MM-dd'), 33 | }; 34 | } 35 | 36 | if ( 37 | transaction.remittanceInformationUnstructuredArray[0].startsWith( 38 | 'Bizum payment to: ', 39 | ) 40 | ) { 41 | const date = 42 | transaction.bookingDate || 43 | transaction.bookingDateTime || 44 | transaction.valueDate || 45 | transaction.valueDateTime; 46 | 47 | return { 48 | ...transaction, 49 | payeeName: formatPayeeName(transaction), 50 | remittanceInformationUnstructured: 51 | transaction.remittanceInformationUnstructuredArray[1], 52 | date: d.format(d.parseISO(date), 'yyyy-MM-dd'), 53 | }; 54 | } 55 | 56 | return Fallback.normalizeTransaction(transaction, _booked); 57 | }, 58 | }; 59 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/sandboxfinance_sfin0000.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { amountToInteger } from '../utils.js'; 4 | import { formatPayeeName } from '../../util/payee-name.js'; 5 | 6 | /** @type {import('./bank.interface.js').IBank} */ 7 | export default { 8 | ...Fallback, 9 | 10 | institutionIds: ['SANDBOXFINANCE_SFIN0000'], 11 | 12 | /** 13 | * Following the GoCardless documentation[0] we should prefer `bookingDate` 14 | * here, though some of their bank integrations uses the date field 15 | * differently from what's described in their documentation and so it's 16 | * sometimes necessary to use `valueDate` instead. 17 | * 18 | * [0]: https://nordigen.zendesk.com/hc/en-gb/articles/7899367372829-valueDate-and-bookingDate-for-transactions 19 | */ 20 | normalizeTransaction(transaction, _booked) { 21 | return { 22 | ...transaction, 23 | payeeName: formatPayeeName(transaction), 24 | date: transaction.bookingDate || transaction.valueDate, 25 | }; 26 | }, 27 | 28 | /** 29 | * For SANDBOXFINANCE_SFIN0000 we don't know what balance was 30 | * after each transaction so we have to calculate it by getting 31 | * current balance from the account and subtract all the transactions 32 | * 33 | * As a current balance we use `interimBooked` balance type because 34 | * it includes transaction placed during current day 35 | */ 36 | calculateStartingBalance(sortedTransactions = [], balances = []) { 37 | const currentBalance = balances.find( 38 | (balance) => 'interimAvailable' === balance.balanceType, 39 | ); 40 | 41 | return sortedTransactions.reduce((total, trans) => { 42 | return total - amountToInteger(trans.transactionAmount.amount); 43 | }, amountToInteger(currentBalance.balanceAmount.amount)); 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/seb_kort_bank_ab.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { amountToInteger } from '../utils.js'; 4 | import { formatPayeeName } from '../../util/payee-name.js'; 5 | 6 | /** @type {import('./bank.interface.js').IBank} */ 7 | export default { 8 | ...Fallback, 9 | 10 | institutionIds: [ 11 | 'SEB_KORT_AB_NO_SKHSFI21', 12 | 'SEB_KORT_AB_SE_SKHSFI21', 13 | 'SEB_CARD_ESSESESS', 14 | ], 15 | 16 | /** 17 | * Sign of transaction amount needs to be flipped for SEB credit cards 18 | */ 19 | normalizeTransaction(transaction, _booked) { 20 | // Creditor name is stored in additionInformation for SEB 21 | transaction.creditorName = transaction.additionalInformation; 22 | transaction.transactionAmount = { 23 | // Flip transaction amount sign 24 | amount: (-parseFloat(transaction.transactionAmount.amount)).toString(), 25 | currency: transaction.transactionAmount.currency, 26 | }; 27 | 28 | return { 29 | ...transaction, 30 | payeeName: formatPayeeName(transaction), 31 | date: transaction.valueDate, 32 | }; 33 | }, 34 | 35 | /** 36 | * For SEB_KORT_AB_NO_SKHSFI21 and SEB_KORT_AB_SE_SKHSFI21 we don't know what balance was 37 | * after each transaction so we have to calculate it by getting 38 | * current balance from the account and subtract all the transactions 39 | * 40 | * As a current balance we use `expected` and `nonInvoiced` balance types because it 41 | * corresponds to the current running balance, whereas `interimAvailable` 42 | * holds the remaining credit limit. 43 | */ 44 | calculateStartingBalance(sortedTransactions = [], balances = []) { 45 | const currentBalance = balances.find( 46 | (balance) => 'expected' === balance.balanceType, 47 | ); 48 | 49 | const nonInvoiced = balances.find( 50 | (balance) => 'nonInvoiced' === balance.balanceType, 51 | ); 52 | 53 | return sortedTransactions.reduce((total, trans) => { 54 | return total - amountToInteger(trans.transactionAmount.amount); 55 | }, -amountToInteger(currentBalance.balanceAmount.amount) + amountToInteger(nonInvoiced.balanceAmount.amount)); 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/seb_privat.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import * as d from 'date-fns'; 4 | import { amountToInteger } from '../utils.js'; 5 | import { formatPayeeName } from '../../util/payee-name.js'; 6 | 7 | /** @type {import('./bank.interface.js').IBank} */ 8 | export default { 9 | ...Fallback, 10 | 11 | institutionIds: ['SEB_ESSESESS_PRIVATE'], 12 | 13 | normalizeTransaction(transaction, _booked) { 14 | const date = 15 | transaction.bookingDate || 16 | transaction.bookingDateTime || 17 | transaction.valueDate || 18 | transaction.valueDateTime; 19 | // If we couldn't find a valid date field we filter out this transaction 20 | // and hope that we will import it again once the bank has processed the 21 | // transaction further. 22 | if (!date) { 23 | return null; 24 | } 25 | 26 | // Creditor name is stored in additionInformation for SEB 27 | transaction.creditorName = transaction.additionalInformation; 28 | 29 | return { 30 | ...transaction, 31 | payeeName: formatPayeeName(transaction), 32 | date: d.format(d.parseISO(date), 'yyyy-MM-dd'), 33 | }; 34 | }, 35 | 36 | calculateStartingBalance(sortedTransactions = [], balances = []) { 37 | const currentBalance = balances.find( 38 | (balance) => 'interimBooked' === balance.balanceType, 39 | ); 40 | 41 | return sortedTransactions.reduce((total, trans) => { 42 | return total - amountToInteger(trans.transactionAmount.amount); 43 | }, amountToInteger(currentBalance.balanceAmount.amount)); 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/sparnord_spnodk22.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { formatPayeeName } from '../../util/payee-name.js'; 4 | 5 | /** @type {import('./bank.interface.js').IBank} */ 6 | export default { 7 | ...Fallback, 8 | 9 | institutionIds: [ 10 | 'SPARNORD_SPNODK22', 11 | 'LAGERNES_BANK_LAPNDKK1', 12 | 'ANDELSKASSEN_FALLESKASSEN_FAELDKK1', 13 | ], 14 | 15 | /** 16 | * Banks on the BEC backend only give information regarding the transaction in additionalInformation 17 | */ 18 | normalizeTransaction(transaction, _booked) { 19 | transaction.remittanceInformationUnstructured = 20 | transaction.additionalInformation; 21 | 22 | return { 23 | ...transaction, 24 | payeeName: formatPayeeName(transaction), 25 | date: transaction.bookingDate, 26 | }; 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/spk_karlsruhe_karsde66.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { amountToInteger } from '../utils.js'; 4 | import { formatPayeeName } from '../../util/payee-name.js'; 5 | 6 | /** @type {import('./bank.interface.js').IBank} */ 7 | export default { 8 | ...Fallback, 9 | 10 | institutionIds: ['SPK_KARLSRUHE_KARSDE66XXX'], 11 | 12 | /** 13 | * Following the GoCardless documentation[0] we should prefer `bookingDate` 14 | * here, though some of their bank integrations uses the date field 15 | * differently from what's described in their documentation and so it's 16 | * sometimes necessary to use `valueDate` instead. 17 | * 18 | * [0]: https://nordigen.zendesk.com/hc/en-gb/articles/7899367372829-valueDate-and-bookingDate-for-transactions 19 | */ 20 | normalizeTransaction(transaction, _booked) { 21 | const date = 22 | transaction.bookingDate || 23 | transaction.bookingDateTime || 24 | transaction.valueDate || 25 | transaction.valueDateTime; 26 | 27 | // If we couldn't find a valid date field we filter out this transaction 28 | // and hope that we will import it again once the bank has processed the 29 | // transaction further. 30 | if (!date) { 31 | return null; 32 | } 33 | 34 | let remittanceInformationUnstructured; 35 | 36 | if (transaction.remittanceInformationUnstructured) { 37 | remittanceInformationUnstructured = 38 | transaction.remittanceInformationUnstructured; 39 | } else if (transaction.remittanceInformationStructured) { 40 | remittanceInformationUnstructured = 41 | transaction.remittanceInformationStructured; 42 | } else if (transaction.remittanceInformationStructuredArray?.length > 0) { 43 | remittanceInformationUnstructured = 44 | transaction.remittanceInformationStructuredArray?.join(' '); 45 | } 46 | 47 | if (transaction.additionalInformation) 48 | remittanceInformationUnstructured += 49 | ' ' + transaction.additionalInformation; 50 | 51 | const usefulCreditorName = 52 | transaction.ultimateCreditor || 53 | transaction.creditorName || 54 | transaction.debtorName; 55 | 56 | transaction.creditorName = usefulCreditorName; 57 | transaction.remittanceInformationUnstructured = 58 | remittanceInformationUnstructured; 59 | 60 | return { 61 | ...transaction, 62 | payeeName: formatPayeeName(transaction), 63 | date: transaction.bookingDate || transaction.valueDate, 64 | }; 65 | }, 66 | 67 | /** 68 | * For SANDBOXFINANCE_SFIN0000 we don't know what balance was 69 | * after each transaction so we have to calculate it by getting 70 | * current balance from the account and subtract all the transactions 71 | * 72 | * As a current balance we use `interimBooked` balance type because 73 | * it includes transaction placed during current day 74 | */ 75 | calculateStartingBalance(sortedTransactions = [], balances = []) { 76 | const currentBalance = balances.find( 77 | (balance) => 'interimAvailable' === balance.balanceType, 78 | ); 79 | 80 | return sortedTransactions.reduce((total, trans) => { 81 | return total - amountToInteger(trans.transactionAmount.amount); 82 | }, amountToInteger(currentBalance.balanceAmount.amount)); 83 | }, 84 | }; 85 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/spk_marburg_biedenkopf_heladef1mar.js: -------------------------------------------------------------------------------- 1 | import d from 'date-fns'; 2 | 3 | import Fallback from './integration-bank.js'; 4 | 5 | import { formatPayeeName } from '../../util/payee-name.js'; 6 | 7 | /** @type {import('./bank.interface.js').IBank} */ 8 | export default { 9 | ...Fallback, 10 | 11 | institutionIds: ['SPK_MARBURG_BIEDENKOPF_HELADEF1MAR'], 12 | 13 | normalizeTransaction(transaction, _booked) { 14 | const date = 15 | transaction.bookingDate || 16 | transaction.bookingDateTime || 17 | transaction.valueDate || 18 | transaction.valueDateTime; 19 | 20 | // If we couldn't find a valid date field we filter out this transaction 21 | // and hope that we will import it again once the bank has processed the 22 | // transaction further. 23 | if (!date) { 24 | return null; 25 | } 26 | 27 | let remittanceInformationUnstructured; 28 | 29 | if (transaction.remittanceInformationUnstructured) { 30 | remittanceInformationUnstructured = 31 | transaction.remittanceInformationUnstructured; 32 | } else if (transaction.remittanceInformationStructured) { 33 | remittanceInformationUnstructured = 34 | transaction.remittanceInformationStructured; 35 | } else if (transaction.remittanceInformationStructuredArray?.length > 0) { 36 | remittanceInformationUnstructured = 37 | transaction.remittanceInformationStructuredArray?.join(' '); 38 | } 39 | 40 | transaction.remittanceInformationUnstructured = 41 | remittanceInformationUnstructured; 42 | 43 | return { 44 | ...transaction, 45 | payeeName: formatPayeeName(transaction), 46 | date: d.format(d.parseISO(date), 'yyyy-MM-dd'), 47 | }; 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/spk_worms_alzey_ried_malade51wor.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | import { formatPayeeName } from '../../util/payee-name.js'; 4 | 5 | /** @type {import('./bank.interface.js').IBank} */ 6 | export default { 7 | ...Fallback, 8 | 9 | institutionIds: ['SPK_WORMS_ALZEY_RIED_MALADE51WOR'], 10 | 11 | normalizeTransaction(transaction, _booked) { 12 | const date = transaction.bookingDate || transaction.valueDate; 13 | if (!date) { 14 | return null; 15 | } 16 | 17 | transaction.remittanceInformationUnstructured = 18 | transaction.remittanceInformationUnstructured ?? 19 | transaction.remittanceInformationStructured ?? 20 | transaction.remittanceInformationStructuredArray?.join(' '); 21 | return { 22 | ...transaction, 23 | payeeName: formatPayeeName(transaction), 24 | date: transaction.bookingDate || transaction.valueDate, 25 | }; 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/ssk_dusseldorf_dussdeddxxx.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | /** @type {import('./bank.interface.js').IBank} */ 4 | export default { 5 | ...Fallback, 6 | 7 | institutionIds: ['SSK_DUSSELDORF_DUSSDEDDXXX'], 8 | 9 | normalizeTransaction(transaction, _booked) { 10 | // If the transaction is not booked yet by the bank, don't import it. 11 | // Reason being that the transaction doesn't have the information yet 12 | // to make the payee and notes field be of any use. It's filled with 13 | // a placeholder text and wouldn't be corrected on the next sync. 14 | if (!_booked) { 15 | console.debug( 16 | 'Skipping unbooked transaction:', 17 | transaction.transactionId, 18 | ); 19 | return null; 20 | } 21 | 22 | // Prioritize unstructured information, falling back to structured formats 23 | let remittanceInformationUnstructured = 24 | transaction.remittanceInformationUnstructured ?? 25 | transaction.remittanceInformationStructured ?? 26 | transaction.remittanceInformationStructuredArray?.join(' '); 27 | 28 | if (transaction.additionalInformation) 29 | remittanceInformationUnstructured = [ 30 | remittanceInformationUnstructured, 31 | transaction.additionalInformation, 32 | ] 33 | .filter(Boolean) 34 | .join(' '); 35 | 36 | const usefulCreditorName = 37 | transaction.ultimateCreditor || 38 | transaction.creditorName || 39 | transaction.debtorName; 40 | 41 | transaction.creditorName = usefulCreditorName; 42 | transaction.remittanceInformationUnstructured = 43 | remittanceInformationUnstructured; 44 | 45 | return Fallback.normalizeTransaction(transaction, _booked); 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/swedbank_habalv22.js: -------------------------------------------------------------------------------- 1 | import d from 'date-fns'; 2 | 3 | import Fallback from './integration-bank.js'; 4 | 5 | /** @type {import('./bank.interface.js').IBank} */ 6 | export default { 7 | ...Fallback, 8 | 9 | institutionIds: ['SWEDBANK_HABALV22'], 10 | 11 | /** 12 | * The actual transaction date for card transactions is only available in the remittanceInformationUnstructured field when the transaction is booked. 13 | */ 14 | normalizeTransaction(transaction, booked) { 15 | const isCardTransaction = 16 | transaction.remittanceInformationUnstructured?.startsWith('PIRKUMS'); 17 | 18 | if (isCardTransaction) { 19 | if (!booked && !transaction.creditorName) { 20 | const creditorNameMatch = 21 | transaction.remittanceInformationUnstructured?.match( 22 | /PIRKUMS [\d*]+ \d{2}\.\d{2}\.\d{2} \d{2}:\d{2} [\d.]+ \w{3} \(\d+\) (.+)/, 23 | ); 24 | 25 | if (creditorNameMatch) { 26 | transaction = { 27 | ...transaction, 28 | creditorName: creditorNameMatch[1], 29 | }; 30 | } 31 | } 32 | 33 | const dateMatch = transaction.remittanceInformationUnstructured?.match( 34 | /PIRKUMS [\d*]+ (\d{2}\.\d{2}\.\d{4})/, 35 | ); 36 | 37 | if (dateMatch) { 38 | const extractedDate = d.parse(dateMatch[1], 'dd.MM.yyyy', new Date()); 39 | 40 | transaction = { 41 | ...transaction, 42 | bookingDate: d.format(extractedDate, 'yyyy-MM-dd'), 43 | }; 44 | } 45 | } 46 | 47 | return Fallback.normalizeTransaction(transaction, booked); 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/tests/abanca_caglesmm.spec.js: -------------------------------------------------------------------------------- 1 | import Abanca from '../abanca_caglesmm.js'; 2 | import { mockTransactionAmount } from '../../services/tests/fixtures.js'; 3 | 4 | describe('Abanca', () => { 5 | describe('#normalizeTransaction', () => { 6 | it('returns the creditorName and debtorName as remittanceInformationStructured', () => { 7 | const transaction = { 8 | transactionId: 'non-unique-id', 9 | internalTransactionId: 'D202301180000003', 10 | transactionAmount: mockTransactionAmount, 11 | remittanceInformationStructured: 'some-creditor-name', 12 | }; 13 | const normalizedTransaction = Abanca.normalizeTransaction( 14 | transaction, 15 | true, 16 | ); 17 | expect(normalizedTransaction.creditorName).toEqual( 18 | transaction.remittanceInformationStructured, 19 | ); 20 | expect(normalizedTransaction.debtorName).toEqual( 21 | transaction.remittanceInformationStructured, 22 | ); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/tests/abnamro_abnanl2a.spec.js: -------------------------------------------------------------------------------- 1 | import AbnamroAbnanl2a from '../abnamro_abnanl2a.js'; 2 | 3 | describe('AbnamroAbnanl2a', () => { 4 | describe('#normalizeTransaction', () => { 5 | it('correctly extracts the payee and when not provided', () => { 6 | const transaction = { 7 | transactionId: '0123456789012345', 8 | bookingDate: '2023-12-11', 9 | valueDateTime: '2023-12-09T15:43:37.950', 10 | transactionAmount: { 11 | amount: '-10.00', 12 | currency: 'EUR', 13 | }, 14 | remittanceInformationUnstructuredArray: [ 15 | 'BEA, Betaalpas', 16 | 'My Payee Name,PAS123', 17 | 'NR:123A4B, 09.12.23/15:43', 18 | 'CITY', 19 | ], 20 | }; 21 | 22 | const normalizedTransaction = AbnamroAbnanl2a.normalizeTransaction( 23 | transaction, 24 | false, 25 | ); 26 | 27 | expect(normalizedTransaction.remittanceInformationUnstructured).toEqual( 28 | 'BEA, Betaalpas, My Payee Name,PAS123, NR:123A4B, 09.12.23/15:43, CITY', 29 | ); 30 | expect(normalizedTransaction.payeeName).toEqual('My Payee Name'); 31 | }); 32 | 33 | it('correctly extracts the payee for google pay', () => { 34 | const transaction = { 35 | transactionId: '0123456789012345', 36 | bookingDate: '2023-12-11', 37 | valueDateTime: '2023-12-09T15:43:37.950', 38 | transactionAmount: { 39 | amount: '-10.00', 40 | currency: 'EUR', 41 | }, 42 | remittanceInformationUnstructuredArray: [ 43 | 'BEA, Google Pay', 44 | 'CCV*Other payee name,PAS123', 45 | 'NR:123A4B, 09.12.23/15:43', 46 | 'CITY', 47 | ], 48 | }; 49 | 50 | const normalizedTransaction = AbnamroAbnanl2a.normalizeTransaction( 51 | transaction, 52 | false, 53 | ); 54 | 55 | expect(normalizedTransaction.remittanceInformationUnstructured).toEqual( 56 | 'BEA, Google Pay, CCV*Other payee name,PAS123, NR:123A4B, 09.12.23/15:43, CITY', 57 | ); 58 | expect(normalizedTransaction.payeeName).toEqual('Other Payee Name'); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/tests/bancsabadell_bsabesbbb.spec.js: -------------------------------------------------------------------------------- 1 | import Sabadell from '../bancsabadell_bsabesbbb.js'; 2 | 3 | describe('BancSabadell', () => { 4 | describe('#normalizeTransaction', () => { 5 | describe('returns the creditorName and debtorName from remittanceInformationUnstructuredArray', () => { 6 | it('debtor role - amount < 0', () => { 7 | const transaction = { 8 | transactionAmount: { amount: '-100', currency: 'EUR' }, 9 | remittanceInformationUnstructuredArray: ['some-creditor-name'], 10 | internalTransactionId: 'd7dca139cf31d9', 11 | transactionId: '04704109322', 12 | bookingDate: '2022-05-01', 13 | }; 14 | const normalizedTransaction = Sabadell.normalizeTransaction( 15 | transaction, 16 | true, 17 | ); 18 | expect(normalizedTransaction.creditorName).toEqual( 19 | 'some-creditor-name', 20 | ); 21 | expect(normalizedTransaction.debtorName).toEqual(null); 22 | }); 23 | 24 | it('creditor role - amount > 0', () => { 25 | const transaction = { 26 | transactionAmount: { amount: '100', currency: 'EUR' }, 27 | remittanceInformationUnstructuredArray: ['some-debtor-name'], 28 | internalTransactionId: 'd7dca139cf31d9', 29 | transactionId: '04704109322', 30 | bookingDate: '2022-05-01', 31 | }; 32 | const normalizedTransaction = Sabadell.normalizeTransaction( 33 | transaction, 34 | true, 35 | ); 36 | expect(normalizedTransaction.debtorName).toEqual('some-debtor-name'); 37 | expect(normalizedTransaction.creditorName).toEqual(null); 38 | }); 39 | }); 40 | 41 | it('extract date', () => { 42 | const transaction = { 43 | transactionAmount: { amount: '-100', currency: 'EUR' }, 44 | remittanceInformationUnstructuredArray: ['some-creditor-name'], 45 | internalTransactionId: 'd7dca139cf31d9', 46 | transactionId: '04704109322', 47 | bookingDate: '2024-10-02', 48 | valueDate: '2024-10-05', 49 | }; 50 | const normalizedTransaction = Sabadell.normalizeTransaction( 51 | transaction, 52 | true, 53 | ); 54 | expect(normalizedTransaction.date).toEqual('2024-10-02'); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/tests/belfius_gkccbebb.spec.js: -------------------------------------------------------------------------------- 1 | import Belfius from '../belfius_gkccbebb.js'; 2 | import { mockTransactionAmount } from '../../services/tests/fixtures.js'; 3 | 4 | describe('Belfius', () => { 5 | describe('#normalizeTransaction', () => { 6 | it('returns the internalTransactionId as transactionId', () => { 7 | const transaction = { 8 | transactionId: 'non-unique-id', 9 | internalTransactionId: 'D202301180000003', 10 | transactionAmount: mockTransactionAmount, 11 | }; 12 | const normalizedTransaction = Belfius.normalizeTransaction( 13 | transaction, 14 | true, 15 | ); 16 | expect(normalizedTransaction.transactionId).toEqual( 17 | transaction.internalTransactionId, 18 | ); 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/tests/cbc_cregbebb.spec.js: -------------------------------------------------------------------------------- 1 | import CBCcregbebb from '../cbc_cregbebb.js'; 2 | 3 | describe('cbc_cregbebb', () => { 4 | describe('#normalizeTransaction', () => { 5 | it('returns the remittanceInformationUnstructured as payeeName when the amount is negative', () => { 6 | const transaction = { 7 | remittanceInformationUnstructured: 8 | 'ONKART FR Viry Paiement Maestro par Carte de débit CBC 05-09-2024 à 15.43 heures 6703 19XX XXXX X201 5 JOHN DOE', 9 | transactionAmount: { amount: '-45.00', currency: 'EUR' }, 10 | }; 11 | const normalizedTransaction = CBCcregbebb.normalizeTransaction( 12 | transaction, 13 | true, 14 | ); 15 | expect(normalizedTransaction.payeeName).toEqual('ONKART FR Viry'); 16 | }); 17 | 18 | it('returns the debtorName as payeeName when the amount is positive', () => { 19 | const transaction = { 20 | debtorName: 'ONKART FR Viry', 21 | remittanceInformationUnstructured: 22 | 'ONKART FR Viry Paiement Maestro par Carte de débit CBC 05-09-2024 à 15.43 heures 6703 19XX XXXX X201 5 JOHN DOE', 23 | transactionAmount: { amount: '10.99', currency: 'EUR' }, 24 | }; 25 | const normalizedTransaction = CBCcregbebb.normalizeTransaction( 26 | transaction, 27 | true, 28 | ); 29 | expect(normalizedTransaction.payeeName).toEqual('ONKART FR Viry'); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/tests/commerzbank_cobadeff.spec.js: -------------------------------------------------------------------------------- 1 | import CommerzbankCobadeff from '../commerzbank_cobadeff.js'; 2 | 3 | describe('CommerzbankCobadeff', () => { 4 | describe('#normalizeTransaction', () => { 5 | it('correctly formats remittanceInformationUnstructured', () => { 6 | const transaction = { 7 | endToEndId: '1234567890', 8 | mandateId: '321654', 9 | bookingDate: '2024-12-20', 10 | valueDate: '2024-12-20', 11 | transactionAmount: { 12 | amount: '-12.34', 13 | currency: 'EUR', 14 | }, 15 | creditorName: 'SHOP NAME CITY DE', 16 | remittanceInformationUnstructured: 17 | 'SHOP NAME//CITY/DE\n2024-12-19T15:34:31 KFN 1 AB 1234\nKartenzahlung', 18 | remittanceInformationUnstructuredArray: [ 19 | 'SHOP NAME//CITY/DE', 20 | '2024-12-19T15:34:31 KFN 1 AB 1234', 21 | 'Kartenzahlung', 22 | ], 23 | remittanceInformationStructured: 24 | 'SHOP NAME//CITY/DE 2024-12-19T15:34:31 KFN 1 AB 1234 Kartenzahlung', 25 | internalTransactionId: '3815213adb654baeadfb231c853', 26 | }; 27 | const normalizedTransaction = CommerzbankCobadeff.normalizeTransaction( 28 | transaction, 29 | false, 30 | ); 31 | expect(normalizedTransaction.remittanceInformationUnstructured).toEqual( 32 | '2024-12-19T15:34:31 KFN 1 AB 1234, Kartenzahlung', 33 | ); 34 | }); 35 | 36 | it('correctly formats remittanceInformationUnstructured; repair split keyword', () => { 37 | const transaction = { 38 | endToEndId: '901234567890', 39 | mandateId: 'ABC123DEF456', 40 | bookingDate: '2024-10-11', 41 | valueDate: '2024-10-11', 42 | transactionAmount: { 43 | amount: '-56.78', 44 | currency: 'EUR', 45 | }, 46 | creditorName: 'Long payee name that is eaxtly 35ch', 47 | remittanceInformationUnstructured: 48 | 'Long payee name that is eaxtly 35ch\n901234567890/. Long description tha\nt gets cut and is very long, did I\nmention it is long\nEnd-to-En', 49 | remittanceInformationUnstructuredArray: [ 50 | 'Long payee name that is eaxtly 35ch', 51 | '901234567890/. Long description tha', 52 | 't gets cut and is very long, did I', 53 | 'mention it is long', 54 | 'End-to-En', 55 | 'd-Ref.: 901234567890', 56 | 'Mandatsref: ABC123DEF456', 57 | 'Gläubiger-ID:', 58 | 'AB12CDE0000000000000000012', 59 | 'SEPA-BASISLASTSCHRIFT wiederholend', 60 | ], 61 | remittanceInformationStructured: 62 | 'Long payee name that is eaxtly 35ch 901234567890/. Long description tha t gets cut and is very long, did I mention it is long End-to-En', 63 | internalTransactionId: '812354cfdea36465asdfe', 64 | }; 65 | const normalizedTransaction = CommerzbankCobadeff.normalizeTransaction( 66 | transaction, 67 | false, 68 | ); 69 | expect(normalizedTransaction.remittanceInformationUnstructured).toEqual( 70 | '901234567890/. Long description tha t gets cut and is very long, did I mention it is long, End-to-End-Ref.: 901234567890, Mandatsref: ABC123DEF456, Gläubiger-ID: AB12CDE0000000000000000012, SEPA-BASISLASTSCHRIFT wiederholend', 71 | ); 72 | }); 73 | 74 | it('correctly formats remittanceInformationUnstructured; removing NOTPROVIDED', () => { 75 | const transaction = { 76 | endToEndId: 'NOTPROVIDED', 77 | bookingDate: '2024-12-02', 78 | valueDate: '2024-12-02', 79 | transactionAmount: { 80 | amount: '-9', 81 | currency: 'EUR', 82 | }, 83 | creditorName: 'CREDITOR NAME', 84 | creditorAccount: { 85 | iban: 'CREDITOR000IBAN', 86 | }, 87 | remittanceInformationUnstructured: 88 | 'CREDITOR NAME\nCREDITOR00BIC\nCREDITOR000IBAN\nDESCRIPTION\nEnd-to-End-Ref.: NOTPROVIDED\nDauerauftrag', 89 | remittanceInformationUnstructuredArray: [ 90 | 'CREDITOR NAME', 91 | 'CREDITOR00BIC', 92 | 'CREDITOR000IBAN', 93 | 'DESCRIPTION', 94 | 'End-to-End-Ref.: NOTPROVIDED', 95 | 'Dauerauftrag', 96 | ], 97 | remittanceInformationStructured: 98 | 'CREDITOR NAME CREDITOR00BIC CREDITOR000IBAN DESCRIPTION End-to-End-Ref.: NOTPROVIDED Dauerauftrag', 99 | internalTransactionId: 'f617dc31ab77622bf13d6c95d6dd8b4a', 100 | }; 101 | const normalizedTransaction = CommerzbankCobadeff.normalizeTransaction( 102 | transaction, 103 | false, 104 | ); 105 | expect(normalizedTransaction.remittanceInformationUnstructured).toEqual( 106 | 'CREDITOR00BIC CREDITOR000IBAN DESCRIPTION, Dauerauftrag', 107 | ); 108 | }); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/tests/easybank_bawaatww.spec.js: -------------------------------------------------------------------------------- 1 | import EasybankBawaatww from '../easybank_bawaatww.js'; 2 | import { mockTransactionAmount } from '../../services/tests/fixtures.js'; 3 | 4 | describe('easybank', () => { 5 | describe('#normalizeTransaction', () => { 6 | it('returns the expected payeeName from a transaction with a set creditorName', () => { 7 | const transaction = { 8 | creditorName: 'Some Payee Name', 9 | transactionAmount: mockTransactionAmount, 10 | bookingDate: '2024-01-01', 11 | creditorAccount: 'AT611904300234573201', 12 | }; 13 | 14 | const normalizedTransaction = EasybankBawaatww.normalizeTransaction( 15 | transaction, 16 | true, 17 | ); 18 | 19 | expect(normalizedTransaction.payeeName).toEqual('Some Payee Name'); 20 | }); 21 | 22 | it('returns the expected payeeName from a transaction with payee name inside structuredInformation', () => { 23 | const transaction = { 24 | payeeName: '', 25 | transactionAmount: mockTransactionAmount, 26 | remittanceInformationStructured: 27 | 'Bezahlung Karte MC/000001234POS 1234 K001 12.12. 23:59SOME PAYEE NAME\\\\LOCATION\\1', 28 | bookingDate: '2023-12-31', 29 | }; 30 | const normalizedTransaction = EasybankBawaatww.normalizeTransaction( 31 | transaction, 32 | true, 33 | ); 34 | expect(normalizedTransaction.payeeName).toEqual('Some Payee Name'); 35 | }); 36 | 37 | it('returns the full structured information as payeeName from a transaction with no payee name', () => { 38 | const transaction = { 39 | payeeName: '', 40 | transactionAmount: mockTransactionAmount, 41 | remittanceInformationStructured: 42 | 'Auszahlung Karte MC/000001234AUTOMAT 00012345 K001 31.12. 23:59', 43 | bookingDate: '2023-12-31', 44 | }; 45 | const normalizedTransaction = EasybankBawaatww.normalizeTransaction( 46 | transaction, 47 | true, 48 | ); 49 | expect(normalizedTransaction.payeeName).toEqual( 50 | 'Auszahlung Karte MC/000001234AUTOMAT 00012345 K001 31.12. 23:59', 51 | ); 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/tests/kbc_kredbebb.spec.js: -------------------------------------------------------------------------------- 1 | import KBCkredbebb from '../kbc_kredbebb.js'; 2 | 3 | describe('kbc_kredbebb', () => { 4 | describe('#normalizeTransaction', () => { 5 | it('returns the remittanceInformationUnstructured as payeeName when the amount is negative', () => { 6 | const transaction = { 7 | remittanceInformationUnstructured: 8 | 'CARREFOUR ST GIL BE1060 BRUXELLES Betaling met Google Pay via Debit Mastercard 28-08-2024 om 19.15 uur 5127 04XX XXXX 1637 5853 98XX XXXX 2266 JOHN SMITH', 9 | transactionAmount: { amount: '-10.99', currency: 'EUR' }, 10 | }; 11 | const normalizedTransaction = KBCkredbebb.normalizeTransaction( 12 | transaction, 13 | true, 14 | ); 15 | expect(normalizedTransaction.payeeName).toEqual( 16 | 'CARREFOUR ST GIL BE1060 BRUXELLES', 17 | ); 18 | }); 19 | 20 | it('returns the debtorName as payeeName when the amount is positive', () => { 21 | const transaction = { 22 | debtorName: 'CARREFOUR ST GIL BE1060 BRUXELLES', 23 | remittanceInformationUnstructured: 24 | 'CARREFOUR ST GIL BE1060 BRUXELLES Betaling met Google Pay via Debit Mastercard 28-08-2024 om 19.15 uur 5127 04XX XXXX 1637 5853 98XX XXXX 2266 JOHN SMITH', 25 | transactionAmount: { amount: '10.99', currency: 'EUR' }, 26 | }; 27 | const normalizedTransaction = KBCkredbebb.normalizeTransaction( 28 | transaction, 29 | true, 30 | ); 31 | expect(normalizedTransaction.payeeName).toEqual( 32 | 'CARREFOUR ST GIL BE1060 BRUXELLES', 33 | ); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/tests/lhv-lhvbee22.spec.js: -------------------------------------------------------------------------------- 1 | import LhvLhvbee22 from '../lhv-lhvbee22.js'; 2 | 3 | describe('#normalizeTransaction', () => { 4 | const bookedCardTransaction = { 5 | transactionId: '2025010300000000-1', 6 | bookingDate: '2025-01-03', 7 | valueDate: '2025-01-03', 8 | transactionAmount: { 9 | amount: '-22.99', 10 | currency: 'EUR', 11 | }, 12 | creditorName: null, 13 | remittanceInformationUnstructured: 14 | '(..1234) 2025-01-02 09:32 CrustumOU\\Poordi 3\\Tallinn\\10156 ESTEST', 15 | bankTransactionCode: 'PMNT-CCRD-POSD', 16 | internalTransactionId: 'fa000f86afb2cc7678bcff0000000000', 17 | }; 18 | 19 | it('extracts booked card transaction creditor name', () => { 20 | expect( 21 | LhvLhvbee22.normalizeTransaction(bookedCardTransaction, true) 22 | .creditorName, 23 | ).toEqual('CrustumOU'); 24 | }); 25 | 26 | it('extracts booked card transaction date', () => { 27 | expect( 28 | LhvLhvbee22.normalizeTransaction(bookedCardTransaction, true).bookingDate, 29 | ).toEqual('2025-01-02'); 30 | 31 | expect( 32 | LhvLhvbee22.normalizeTransaction(bookedCardTransaction, true).date, 33 | ).toEqual('2025-01-02'); 34 | }); 35 | 36 | it.each([ 37 | ['regular text', 'Some info'], 38 | ['partial card text', 'PIRKUMS xxx'], 39 | ['null value', null], 40 | ['invalid date', '(..1234) 2025-13-45 09:32 Merchant\\Address'], 41 | ])('normalizes non-card transaction with %s', (_, remittanceInfo) => { 42 | const transaction = { 43 | ...bookedCardTransaction, 44 | remittanceInformationUnstructured: remittanceInfo, 45 | }; 46 | const normalized = LhvLhvbee22.normalizeTransaction(transaction, true); 47 | 48 | expect(normalized.bookingDate).toEqual('2025-01-03'); 49 | expect(normalized.date).toEqual('2025-01-03'); 50 | }); 51 | 52 | const pendingCardTransaction = { 53 | transactionId: '2025010300000000-1', 54 | valueDate: '2025-01-03', 55 | transactionAmount: { 56 | amount: '-22.99', 57 | currency: 'EUR', 58 | }, 59 | remittanceInformationUnstructured: 60 | '(..1234) 2025-01-02 09:32 CrustumOU\\Poordi 3\\Tallinn\\10156 ESTEST', 61 | }; 62 | 63 | it('extracts pending card transaction creditor name', () => { 64 | expect( 65 | LhvLhvbee22.normalizeTransaction(pendingCardTransaction, false) 66 | .creditorName, 67 | ).toEqual('CrustumOU'); 68 | }); 69 | 70 | it('extracts pending card transaction date', () => { 71 | expect( 72 | LhvLhvbee22.normalizeTransaction(pendingCardTransaction, false) 73 | .bookingDate, 74 | ).toEqual(undefined); 75 | 76 | expect( 77 | LhvLhvbee22.normalizeTransaction(pendingCardTransaction, false).date, 78 | ).toEqual('2025-01-03'); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/tests/nationwide_naiagb21.spec.js: -------------------------------------------------------------------------------- 1 | import Nationwide from '../nationwide_naiagb21.js'; 2 | import { mockTransactionAmount } from '../../services/tests/fixtures.js'; 3 | 4 | describe('Nationwide', () => { 5 | describe('#normalizeTransaction', () => { 6 | it('retains date for booked transaction', () => { 7 | const d = new Date(); 8 | d.setDate(d.getDate() - 7); 9 | 10 | const date = d.toISOString().split('T')[0]; 11 | 12 | const transaction = { 13 | bookingDate: date, 14 | transactionAmount: mockTransactionAmount, 15 | }; 16 | 17 | const normalizedTransaction = Nationwide.normalizeTransaction( 18 | transaction, 19 | true, 20 | ); 21 | 22 | expect(normalizedTransaction.date).toEqual(date); 23 | }); 24 | 25 | it('fixes date for pending transactions', () => { 26 | const d = new Date(); 27 | const date = d.toISOString().split('T')[0]; 28 | 29 | const transaction = { 30 | bookingDate: date, 31 | transactionAmount: mockTransactionAmount, 32 | }; 33 | 34 | const normalizedTransaction = Nationwide.normalizeTransaction( 35 | transaction, 36 | false, 37 | ); 38 | 39 | expect(new Date(normalizedTransaction.date).getTime()).toBeLessThan( 40 | d.getTime(), 41 | ); 42 | }); 43 | 44 | it('keeps transactionId if in the correct format', () => { 45 | const transactionId = 'a896729bb8b30b5ca862fe70bd5967185e2b5d3a'; 46 | const transaction = { 47 | bookingDate: '2024-01-01T00:00:00Z', 48 | transactionId, 49 | transactionAmount: mockTransactionAmount, 50 | }; 51 | 52 | const normalizedTransaction = Nationwide.normalizeTransaction( 53 | transaction, 54 | false, 55 | ); 56 | 57 | expect(normalizedTransaction.transactionId).toBe(transactionId); 58 | }); 59 | 60 | it('unsets transactionId if not valid length', () => { 61 | const transaction = { 62 | bookingDate: '2024-01-01T00:00:00Z', 63 | transactionId: '0123456789', 64 | transactionAmount: mockTransactionAmount, 65 | }; 66 | 67 | const normalizedTransaction = Nationwide.normalizeTransaction( 68 | transaction, 69 | false, 70 | ); 71 | 72 | expect(normalizedTransaction.transactionId).toBeNull(); 73 | }); 74 | 75 | it('unsets transactionId if debit placeholder found', () => { 76 | const transaction = { 77 | bookingDate: '2024-01-01T00:00:00Z', 78 | transactionId: '00DEBIT202401010000000000-1000SUPERMARKET', 79 | transactionAmount: mockTransactionAmount, 80 | }; 81 | 82 | const normalizedTransaction = Nationwide.normalizeTransaction( 83 | transaction, 84 | false, 85 | ); 86 | 87 | expect(normalizedTransaction.transactionId).toBeNull(); 88 | }); 89 | 90 | it('unsets transactionId if credit placeholder found', () => { 91 | const transaction = { 92 | bookingDate: '2024-01-01T00:00:00Z', 93 | transactionId: '00CREDIT202401010000000000-1000SUPERMARKET', 94 | transactionAmount: mockTransactionAmount, 95 | }; 96 | 97 | const normalizedTransaction = Nationwide.normalizeTransaction( 98 | transaction, 99 | false, 100 | ); 101 | 102 | expect(normalizedTransaction.transactionId).toBeNull(); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/tests/nbg_ethngraaxxx.spec.js: -------------------------------------------------------------------------------- 1 | import NbgEthngraaxxx from '../nbg_ethngraaxxx.js'; 2 | 3 | describe('NbgEthngraaxxx', () => { 4 | describe('#normalizeTransaction', () => { 5 | it('provides correct amount in pending transaction and removes payee prefix', () => { 6 | const transaction = { 7 | bookingDate: '2024-09-03', 8 | date: '2024-09-03', 9 | remittanceInformationUnstructured: 'ΑΓΟΡΑ testingson', 10 | transactionAmount: { 11 | amount: '100.00', 12 | currency: 'EUR', 13 | }, 14 | valueDate: '2024-09-03', 15 | }; 16 | 17 | const normalizedTransaction = NbgEthngraaxxx.normalizeTransaction( 18 | transaction, 19 | false, 20 | ); 21 | 22 | expect(normalizedTransaction.transactionAmount.amount).toEqual('-100.00'); 23 | expect(normalizedTransaction.payeeName).toEqual('Testingson'); 24 | }); 25 | }); 26 | 27 | it('provides correct amount and payee in booked transaction', () => { 28 | const transaction = { 29 | transactionId: 'O244015L68IK', 30 | bookingDate: '2024-09-03', 31 | date: '2024-09-03', 32 | remittanceInformationUnstructured: 'testingson', 33 | transactionAmount: { 34 | amount: '-100.00', 35 | currency: 'EUR', 36 | }, 37 | valueDate: '2024-09-03', 38 | }; 39 | 40 | const normalizedTransaction = NbgEthngraaxxx.normalizeTransaction( 41 | transaction, 42 | true, 43 | ); 44 | 45 | expect(normalizedTransaction.transactionAmount.amount).toEqual('-100.00'); 46 | expect(normalizedTransaction.payeeName).toEqual('Testingson'); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/tests/revolut_revolt21.spec.js: -------------------------------------------------------------------------------- 1 | import RevolutRevolt21 from '../revolut_revolt21.js'; 2 | 3 | describe('RevolutRevolt21', () => { 4 | describe('#normalizeTransaction', () => { 5 | it('returns the expected remittanceInformationUnstructured from a bizum expense transfer', () => { 6 | const transaction = { 7 | transactionAmount: { amount: '-1.00', currency: 'EUR' }, 8 | remittanceInformationUnstructuredArray: [ 9 | 'Bizum payment to: CREDITOR NAME', 10 | 'Bizum description', 11 | ], 12 | bookingDate: '2024-09-21', 13 | }; 14 | 15 | const normalizedTransaction = RevolutRevolt21.normalizeTransaction( 16 | transaction, 17 | true, 18 | ); 19 | 20 | expect(normalizedTransaction.remittanceInformationUnstructured).toEqual( 21 | 'Bizum description', 22 | ); 23 | }); 24 | }); 25 | 26 | it('returns the expected payeeName and remittanceInformationUnstructured from a bizum income transfer', () => { 27 | const transaction = { 28 | transactionAmount: { amount: '1.00', currency: 'EUR' }, 29 | remittanceInformationUnstructuredArray: [ 30 | 'Bizum payment from: DEBTOR NAME', 31 | 'Bizum description', 32 | ], 33 | bookingDate: '2024-09-21', 34 | }; 35 | 36 | const normalizedTransaction = RevolutRevolt21.normalizeTransaction( 37 | transaction, 38 | true, 39 | ); 40 | 41 | expect(normalizedTransaction.payeeName).toEqual('DEBTOR NAME'); 42 | expect(normalizedTransaction.remittanceInformationUnstructured).toEqual( 43 | 'Bizum description', 44 | ); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/tests/sandboxfinance_sfin0000.spec.js: -------------------------------------------------------------------------------- 1 | import SandboxfinanceSfin0000 from '../sandboxfinance_sfin0000.js'; 2 | 3 | describe('SandboxfinanceSfin0000', () => { 4 | describe('#normalizeAccount', () => { 5 | /** @type {import('../../gocardless.types.js').DetailedAccountWithInstitution} */ 6 | const accountRaw = { 7 | resourceId: '01F3NS5ASCNMVCTEJDT0G215YE', 8 | iban: 'GL0865354374424724', 9 | currency: 'EUR', 10 | ownerName: 'Jane Doe', 11 | name: 'Main Account', 12 | product: 'Checkings', 13 | cashAccountType: 'CACC', 14 | id: '99a0bfe2-0bef-46df-bff2-e9ae0c6c5838', 15 | created: '2022-02-21T13:43:55.608911Z', 16 | last_accessed: '2023-01-25T16:50:15.078264Z', 17 | institution_id: 'SANDBOXFINANCE_SFIN0000', 18 | status: 'READY', 19 | owner_name: 'Jane Doe', 20 | institution: { 21 | id: 'SANDBOXFINANCE_SFIN0000', 22 | name: 'Sandbox Finance', 23 | bic: 'SFIN0000', 24 | transaction_total_days: '90', 25 | max_access_valid_for_days: '90', 26 | countries: ['XX'], 27 | logo: 'https://cdn.nordigen.com/ais/SANDBOXFINANCE_SFIN0000.png', 28 | supported_payments: {}, 29 | supported_features: [], 30 | }, 31 | }; 32 | 33 | it('returns normalized account data returned to Frontend', () => { 34 | expect(SandboxfinanceSfin0000.normalizeAccount(accountRaw)) 35 | .toMatchInlineSnapshot(` 36 | { 37 | "account_id": "99a0bfe2-0bef-46df-bff2-e9ae0c6c5838", 38 | "iban": "GL0865354374424724", 39 | "institution": { 40 | "bic": "SFIN0000", 41 | "countries": [ 42 | "XX", 43 | ], 44 | "id": "SANDBOXFINANCE_SFIN0000", 45 | "logo": "https://cdn.nordigen.com/ais/SANDBOXFINANCE_SFIN0000.png", 46 | "max_access_valid_for_days": "90", 47 | "name": "Sandbox Finance", 48 | "supported_features": [], 49 | "supported_payments": {}, 50 | "transaction_total_days": "90", 51 | }, 52 | "mask": "4724", 53 | "name": "Main Account (XXX 4724) EUR", 54 | "official_name": "Checkings", 55 | "type": "checking", 56 | } 57 | `); 58 | }); 59 | }); 60 | 61 | describe('#sortTransactions', () => { 62 | it('handles empty arrays', () => { 63 | const transactions = []; 64 | const sortedTransactions = 65 | SandboxfinanceSfin0000.sortTransactions(transactions); 66 | expect(sortedTransactions).toEqual([]); 67 | }); 68 | 69 | it('returns empty array for undefined input', () => { 70 | const sortedTransactions = 71 | SandboxfinanceSfin0000.sortTransactions(undefined); 72 | expect(sortedTransactions).toEqual([]); 73 | }); 74 | }); 75 | 76 | describe('#countStartingBalance', () => { 77 | /** @type {import('../../gocardless-node.types.js').Balance[]} */ 78 | const balances = [ 79 | { 80 | balanceAmount: { amount: '1000.00', currency: 'PLN' }, 81 | balanceType: 'interimAvailable', 82 | }, 83 | ]; 84 | 85 | it('should calculate the starting balance correctly', () => { 86 | const sortedTransactions = [ 87 | { 88 | transactionId: '2022-01-01-1', 89 | transactionAmount: { amount: '-100.00', currency: 'USD' }, 90 | }, 91 | { 92 | transactionId: '2022-01-01-2', 93 | transactionAmount: { amount: '50.00', currency: 'USD' }, 94 | }, 95 | { 96 | transactionId: '2022-01-01-3', 97 | transactionAmount: { amount: '-25.00', currency: 'USD' }, 98 | }, 99 | ]; 100 | 101 | const startingBalance = SandboxfinanceSfin0000.calculateStartingBalance( 102 | sortedTransactions, 103 | balances, 104 | ); 105 | 106 | expect(startingBalance).toEqual(107500); 107 | }); 108 | 109 | it('returns the same balance amount when no transactions', () => { 110 | const transactions = []; 111 | 112 | expect( 113 | SandboxfinanceSfin0000.calculateStartingBalance(transactions, balances), 114 | ).toEqual(100000); 115 | }); 116 | 117 | it('returns the balance minus the available transactions', () => { 118 | /** @type {import('../../gocardless-node.types.js').Transaction[]} */ 119 | const transactions = [ 120 | { 121 | transactionAmount: { amount: '200.00', currency: 'PLN' }, 122 | }, 123 | { 124 | transactionAmount: { amount: '300.50', currency: 'PLN' }, 125 | }, 126 | ]; 127 | 128 | expect( 129 | SandboxfinanceSfin0000.calculateStartingBalance(transactions, balances), 130 | ).toEqual(49950); 131 | }); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/tests/ssk_dusseldorf_dussdeddxxx.spec.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | import SskDusseldorfDussdeddxxx from '../ssk_dusseldorf_dussdeddxxx.js'; 3 | 4 | describe('ssk_dusseldorf_dussdeddxxx', () => { 5 | let consoleSpy; 6 | 7 | beforeEach(() => { 8 | consoleSpy = jest.spyOn(console, 'debug'); 9 | }); 10 | 11 | afterEach(() => { 12 | consoleSpy.mockRestore(); 13 | }); 14 | 15 | describe('#normalizeTransaction', () => { 16 | const bookedTransactionOne = { 17 | transactionId: '2024102900000000-1', 18 | bookingDate: '2024-10-29', 19 | valueDate: '2024-10-29', 20 | transactionAmount: { 21 | amount: '-99.99', 22 | currency: 'EUR', 23 | }, 24 | creditorName: 'a useful creditor name', 25 | remittanceInformationStructured: 'structured information', 26 | remittanceInformationUnstructured: 'unstructured information', 27 | additionalInformation: 'some additional information', 28 | }; 29 | 30 | const bookedTransactionTwo = { 31 | transactionId: '2024102900000000-2', 32 | bookingDate: '2024-10-29', 33 | valueDate: '2024-10-29', 34 | transactionAmount: { 35 | amount: '-99.99', 36 | currency: 'EUR', 37 | }, 38 | creditorName: 'a useful creditor name', 39 | ultimateCreditor: 'ultimate creditor', 40 | remittanceInformationStructured: 'structured information', 41 | additionalInformation: 'some additional information', 42 | }; 43 | 44 | it('properly combines remittance information', () => { 45 | expect( 46 | SskDusseldorfDussdeddxxx.normalizeTransaction( 47 | bookedTransactionOne, 48 | true, 49 | ).remittanceInformationUnstructured, 50 | ).toEqual('unstructured information some additional information'); 51 | 52 | expect( 53 | SskDusseldorfDussdeddxxx.normalizeTransaction( 54 | bookedTransactionTwo, 55 | true, 56 | ).remittanceInformationUnstructured, 57 | ).toEqual('structured information some additional information'); 58 | }); 59 | 60 | it('prioritizes creditor names correctly', () => { 61 | expect( 62 | SskDusseldorfDussdeddxxx.normalizeTransaction( 63 | bookedTransactionOne, 64 | true, 65 | ).payeeName, 66 | ).toEqual('A Useful Creditor Name'); 67 | 68 | expect( 69 | SskDusseldorfDussdeddxxx.normalizeTransaction( 70 | bookedTransactionTwo, 71 | true, 72 | ).payeeName, 73 | ).toEqual('Ultimate Creditor'); 74 | }); 75 | 76 | const unbookedTransaction = { 77 | transactionId: '2024102900000000-1', 78 | valueDate: '2024-10-29', 79 | transactionAmount: { 80 | amount: '-99.99', 81 | currency: 'EUR', 82 | }, 83 | creditorName: 'some nonsensical creditor', 84 | remittanceInformationUnstructured: 'some nonsensical information', 85 | }; 86 | 87 | it('returns null for unbooked transactions', () => { 88 | expect( 89 | SskDusseldorfDussdeddxxx.normalizeTransaction( 90 | unbookedTransaction, 91 | false, 92 | ), 93 | ).toBeNull(); 94 | 95 | expect(consoleSpy).toHaveBeenCalledWith( 96 | 'Skipping unbooked transaction:', 97 | unbookedTransaction.transactionId, 98 | ); 99 | }); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/tests/swedbank_habalv22.spec.js: -------------------------------------------------------------------------------- 1 | import SwedbankHabaLV22 from '../swedbank_habalv22.js'; 2 | 3 | describe('#normalizeTransaction', () => { 4 | const bookedCardTransaction = { 5 | transactionId: '2024102900000000-1', 6 | bookingDate: '2024-10-29', 7 | valueDate: '2024-10-29', 8 | transactionAmount: { 9 | amount: '-22.99', 10 | currency: 'EUR', 11 | }, 12 | creditorName: 'SOME CREDITOR NAME', 13 | remittanceInformationUnstructured: 14 | 'PIRKUMS 424242******4242 28.10.2024 22.99 EUR (111111) SOME CREDITOR NAME', 15 | bankTransactionCode: 'PMNT-CCRD-POSD', 16 | internalTransactionId: 'fa000f86afb2cc7678bcff0000000000', 17 | }; 18 | 19 | it('extracts card transaction date', () => { 20 | expect( 21 | SwedbankHabaLV22.normalizeTransaction(bookedCardTransaction, true) 22 | .bookingDate, 23 | ).toEqual('2024-10-28'); 24 | 25 | expect( 26 | SwedbankHabaLV22.normalizeTransaction(bookedCardTransaction, true).date, 27 | ).toEqual('2024-10-28'); 28 | }); 29 | 30 | it.each([ 31 | ['regular text', 'Some info'], 32 | ['partial card text', 'PIRKUMS xxx'], 33 | ['null value', null], 34 | ])('normalizes non-card transaction with %s', (_, remittanceInfo) => { 35 | const transaction = { 36 | ...bookedCardTransaction, 37 | remittanceInformationUnstructured: remittanceInfo, 38 | }; 39 | const normalized = SwedbankHabaLV22.normalizeTransaction(transaction, true); 40 | 41 | expect(normalized.bookingDate).toEqual('2024-10-29'); 42 | expect(normalized.date).toEqual('2024-10-29'); 43 | }); 44 | 45 | const pendingCardTransaction = { 46 | transactionId: '2024102900000000-1', 47 | valueDate: '2024-10-29', 48 | transactionAmount: { 49 | amount: '-22.99', 50 | currency: 'EUR', 51 | }, 52 | remittanceInformationUnstructured: 53 | 'PIRKUMS 424242******4242 28.10.24 13:37 22.99 EUR (111111) SOME CREDITOR NAME', 54 | }; 55 | 56 | it('extracts pending card transaction creditor name', () => { 57 | expect( 58 | SwedbankHabaLV22.normalizeTransaction(pendingCardTransaction, false) 59 | .creditorName, 60 | ).toEqual('SOME CREDITOR NAME'); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/tests/virgin_nrnbgb22.spec.js: -------------------------------------------------------------------------------- 1 | import Virgin from '../virgin_nrnbgb22.js'; 2 | import { mockTransactionAmount } from '../../services/tests/fixtures.js'; 3 | 4 | describe('Virgin', () => { 5 | describe('#normalizeTransaction', () => { 6 | it('does not alter simple payee information', () => { 7 | const transaction = { 8 | bookingDate: '2024-01-01T00:00:00Z', 9 | remittanceInformationUnstructured: 'DIRECT DEBIT PAYMENT', 10 | transactionAmount: mockTransactionAmount, 11 | }; 12 | 13 | const normalizedTransaction = Virgin.normalizeTransaction( 14 | transaction, 15 | true, 16 | ); 17 | 18 | expect(normalizedTransaction.creditorName).toEqual( 19 | 'DIRECT DEBIT PAYMENT', 20 | ); 21 | expect(normalizedTransaction.debtorName).toEqual('DIRECT DEBIT PAYMENT'); 22 | expect(normalizedTransaction.remittanceInformationUnstructured).toEqual( 23 | 'DIRECT DEBIT PAYMENT', 24 | ); 25 | }); 26 | 27 | it('formats bank transfer payee and references', () => { 28 | const transaction = { 29 | bookingDate: '2024-01-01T00:00:00Z', 30 | remittanceInformationUnstructured: 'FPS, Joe Bloggs, Food', 31 | transactionAmount: mockTransactionAmount, 32 | }; 33 | 34 | const normalizedTransaction = Virgin.normalizeTransaction( 35 | transaction, 36 | true, 37 | ); 38 | 39 | expect(normalizedTransaction.creditorName).toEqual('Joe Bloggs'); 40 | expect(normalizedTransaction.debtorName).toEqual('Joe Bloggs'); 41 | expect(normalizedTransaction.remittanceInformationUnstructured).toEqual( 42 | 'Food', 43 | ); 44 | }); 45 | 46 | it('removes method information from payee name', () => { 47 | const transaction = { 48 | bookingDate: '2024-01-01T00:00:00Z', 49 | remittanceInformationUnstructured: 'Card 99, Tesco Express', 50 | transactionAmount: mockTransactionAmount, 51 | }; 52 | 53 | const normalizedTransaction = Virgin.normalizeTransaction( 54 | transaction, 55 | true, 56 | ); 57 | 58 | expect(normalizedTransaction.creditorName).toEqual('Tesco Express'); 59 | expect(normalizedTransaction.debtorName).toEqual('Tesco Express'); 60 | expect(normalizedTransaction.remittanceInformationUnstructured).toEqual( 61 | 'Card 99, Tesco Express', 62 | ); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/util/extract-payeeName-from-remittanceInfo.js: -------------------------------------------------------------------------------- 1 | /** 2 | /** 3 | * Extracts the payee name from the unstructured remittance information string based on pattern detection. 4 | * 5 | * This function scans the `remittanceInformationUnstructured` string for the presence of 6 | * any of the specified patterns and removes the substring from the position of the last 7 | * occurrence of the most relevant pattern. If no patterns are found, it returns the original string. 8 | * 9 | * @param {string} [remittanceInformationUnstructured=''] - The unstructured remittance information from which to extract the payee name. 10 | * @param {string[]} [patterns=[]] - An array of patterns to look for within the remittance information. 11 | * These patterns are used to identify and remove unwanted parts of the remittance information. 12 | * @returns {string} - The extracted payee name, cleaned of any matched patterns, or the original 13 | * remittance information if no patterns are found. 14 | * 15 | * @example 16 | * const remittanceInfo = 'John Doe Paiement Maestro par Carte de débit CBC 05-09-2024 à 15.43 heures 6703 19XX XXXX X...'; 17 | * const patterns = ['Paiement', 'Domiciliation', 'Transfert', 'Ordre permanent']; 18 | * const payeeName = extractPayeeNameFromRemittanceInfo(remittanceInfo, patterns); // --> 'John Doe' 19 | */ 20 | export function extractPayeeNameFromRemittanceInfo( 21 | remittanceInformationUnstructured, 22 | patterns, 23 | ) { 24 | if (!remittanceInformationUnstructured || !patterns.length) { 25 | return remittanceInformationUnstructured; 26 | } 27 | 28 | const indexForRemoval = patterns.reduce((maxIndex, pattern) => { 29 | const index = remittanceInformationUnstructured.lastIndexOf(pattern); 30 | return index > maxIndex ? index : maxIndex; 31 | }, -1); 32 | 33 | return indexForRemoval > -1 34 | ? remittanceInformationUnstructured.substring(0, indexForRemoval).trim() 35 | : remittanceInformationUnstructured; 36 | } 37 | -------------------------------------------------------------------------------- /src/app-gocardless/banks/virgin_nrnbgb22.js: -------------------------------------------------------------------------------- 1 | import Fallback from './integration-bank.js'; 2 | 3 | /** @type {import('./bank.interface.js').IBank} */ 4 | export default { 5 | ...Fallback, 6 | 7 | institutionIds: ['VIRGIN_NRNBGB22'], 8 | 9 | normalizeTransaction(transaction, booked) { 10 | const transferPrefixes = ['MOB', 'FPS']; 11 | const methodRegex = /^(Card|WLT)\s\d+/; 12 | 13 | const parts = transaction.remittanceInformationUnstructured.split(', '); 14 | 15 | if (transferPrefixes.includes(parts[0])) { 16 | // Transfer remittance information begins with either "MOB" or "FPS" 17 | // the second field contains the payee and the third contains the 18 | // reference 19 | 20 | transaction.creditorName = parts[1]; 21 | transaction.debtorName = parts[1]; 22 | transaction.remittanceInformationUnstructured = parts[2]; 23 | } else if (parts[0].match(methodRegex)) { 24 | // The payee is prefixed with the payment method, eg "Card 11, {payee}" 25 | 26 | transaction.creditorName = parts[1]; 27 | transaction.debtorName = parts[1]; 28 | } else { 29 | // Simple payee name 30 | 31 | transaction.creditorName = transaction.remittanceInformationUnstructured; 32 | transaction.debtorName = transaction.remittanceInformationUnstructured; 33 | } 34 | 35 | return Fallback.normalizeTransaction(transaction, booked); 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /src/app-gocardless/errors.js: -------------------------------------------------------------------------------- 1 | export class RequisitionNotLinked extends Error { 2 | constructor(params = {}) { 3 | super('Requisition not linked yet'); 4 | this.details = params; 5 | } 6 | } 7 | 8 | export class AccountNotLinkedToRequisition extends Error { 9 | constructor(accountId, requisitionId) { 10 | super('Provided account id is not linked to given requisition'); 11 | this.details = { 12 | accountId, 13 | requisitionId, 14 | }; 15 | } 16 | } 17 | 18 | export class GenericGoCardlessError extends Error { 19 | constructor(data = {}) { 20 | super('GoCardless returned error'); 21 | this.details = data; 22 | } 23 | } 24 | 25 | export class GoCardlessClientError extends Error { 26 | constructor(message, details) { 27 | super(message); 28 | this.details = details; 29 | } 30 | } 31 | 32 | export class InvalidInputDataError extends GoCardlessClientError { 33 | constructor(response) { 34 | super('Invalid provided parameters', response); 35 | } 36 | } 37 | 38 | export class InvalidGoCardlessTokenError extends GoCardlessClientError { 39 | constructor(response) { 40 | super('Token is invalid or expired', response); 41 | } 42 | } 43 | 44 | export class AccessDeniedError extends GoCardlessClientError { 45 | constructor(response) { 46 | super('IP address access denied', response); 47 | } 48 | } 49 | 50 | export class NotFoundError extends GoCardlessClientError { 51 | constructor(response) { 52 | super('Resource not found', response); 53 | } 54 | } 55 | 56 | export class ResourceSuspended extends GoCardlessClientError { 57 | constructor(response) { 58 | super( 59 | 'Resource was suspended due to numerous errors that occurred while accessing it', 60 | response, 61 | ); 62 | } 63 | } 64 | 65 | export class RateLimitError extends GoCardlessClientError { 66 | constructor(response) { 67 | super( 68 | 'Daily request limit set by the Institution has been exceeded', 69 | response, 70 | ); 71 | } 72 | } 73 | 74 | export class UnknownError extends GoCardlessClientError { 75 | constructor(response) { 76 | super('Request to Institution returned an error', response); 77 | } 78 | } 79 | 80 | export class ServiceError extends GoCardlessClientError { 81 | constructor(response) { 82 | super('Institution service unavailable', response); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/app-gocardless/gocardless.types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GoCardlessAccountMetadata, 3 | GoCardlessAccountDetails, 4 | Institution, 5 | Transactions, 6 | Balance, 7 | Transaction, 8 | } from './gocardless-node.types.js'; 9 | 10 | export type DetailedAccount = Omit & 11 | GoCardlessAccountMetadata; 12 | export type DetailedAccountWithInstitution = DetailedAccount & { 13 | institution: Institution; 14 | }; 15 | export type TransactionWithBookedStatus = Transaction & { booked: boolean }; 16 | 17 | export type NormalizedAccountDetails = { 18 | /** 19 | * Id of the account 20 | */ 21 | account_id: string; 22 | 23 | /** 24 | * Institution of account 25 | */ 26 | institution: Institution; 27 | 28 | /** 29 | * last 4 digits from the account iban 30 | */ 31 | mask: string; 32 | 33 | /** 34 | * the account iban 35 | */ 36 | iban: string; 37 | 38 | /** 39 | * Name displayed on the UI of Actual app 40 | */ 41 | name: string; 42 | 43 | /** 44 | * name of the product in the institution 45 | */ 46 | official_name: string; 47 | 48 | /** 49 | * type of account 50 | */ 51 | type: string; 52 | }; 53 | 54 | export type GetTransactionsParams = { 55 | /** 56 | * Id of the institution from GoCardless 57 | */ 58 | institutionId: string; 59 | 60 | /** 61 | * Id of account from the GoCardless app 62 | */ 63 | accountId: string; 64 | 65 | /** 66 | * Begin date of the period from which we want to download transactions 67 | */ 68 | startDate: string; 69 | 70 | /** 71 | * End date of the period from which we want to download transactions 72 | */ 73 | endDate?: string; 74 | }; 75 | 76 | export type GetTransactionsResponse = { 77 | status_code?: number; 78 | detail?: string; 79 | transactions: Transactions; 80 | }; 81 | 82 | export type CreateRequisitionParams = { 83 | institutionId: string; 84 | 85 | /** 86 | * Host of your frontend app - on this host you will be redirected after linking with bank 87 | */ 88 | host: string; 89 | }; 90 | 91 | export type GetBalances = { 92 | balances: Balance[]; 93 | }; 94 | -------------------------------------------------------------------------------- /src/app-gocardless/link.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Actual 6 | 7 | 8 | 11 | 12 |

Please wait...

13 |

14 | The window should close automatically. If nothing happened you can close 15 | this window or tab. 16 |

17 | 18 | 19 | -------------------------------------------------------------------------------- /src/app-gocardless/tests/bank-factory.spec.js: -------------------------------------------------------------------------------- 1 | import BankFactory from '../bank-factory.js'; 2 | import { banks } from '../bank-factory.js'; 3 | import IntegrationBank from '../banks/integration-bank.js'; 4 | 5 | describe('BankFactory', () => { 6 | it.each(banks.flatMap((bank) => bank.institutionIds))( 7 | `should return same institutionId`, 8 | (institutionId) => { 9 | const result = BankFactory(institutionId); 10 | 11 | expect(result.institutionIds).toContain(institutionId); 12 | }, 13 | ); 14 | 15 | it('should return IntegrationBank when institutionId is not found', () => { 16 | const institutionId = IntegrationBank.institutionIds[0]; 17 | const result = BankFactory('fake-id-not-found'); 18 | 19 | expect(result.institutionIds).toContain(institutionId); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app-gocardless/util/handle-error.js: -------------------------------------------------------------------------------- 1 | import { inspect } from 'util'; 2 | 3 | export function handleError(func) { 4 | return (req, res) => { 5 | func(req, res).catch((err) => { 6 | console.log('Error', req.originalUrl, inspect(err, { depth: null })); 7 | res.send({ 8 | status: 'ok', 9 | data: { 10 | error_code: 'INTERNAL_ERROR', 11 | error_type: err.message ? err.message : 'internal-error', 12 | }, 13 | }); 14 | }); 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /src/app-gocardless/utils.js: -------------------------------------------------------------------------------- 1 | export const printIban = (account) => { 2 | if (account.iban) { 3 | return '(XXX ' + account.iban.slice(-4) + ')'; 4 | } else { 5 | return ''; 6 | } 7 | }; 8 | 9 | const compareDates = ( 10 | /** @type {string | number | Date | undefined} */ a, 11 | /** @type {string | number | Date | undefined} */ b, 12 | ) => { 13 | if (a == null && b == null) { 14 | return 0; 15 | } else if (a == null) { 16 | return 1; 17 | } else if (b == null) { 18 | return -1; 19 | } 20 | 21 | return +new Date(a) - +new Date(b); 22 | }; 23 | 24 | /** 25 | * @type {(function(*, *): number)[]} 26 | */ 27 | const compareFunctions = [ 28 | (a, b) => compareDates(a.bookingDate, b.bookingDate), 29 | (a, b) => compareDates(a.bookingDateTime, b.bookingDateTime), 30 | (a, b) => compareDates(a.valueDate, b.valueDate), 31 | (a, b) => compareDates(a.valueDateTime, b.valueDateTime), 32 | ]; 33 | 34 | export const sortByBookingDateOrValueDate = (transactions = []) => 35 | transactions.sort((a, b) => { 36 | for (const sortFunction of compareFunctions) { 37 | const result = sortFunction(b, a); 38 | if (result !== 0) { 39 | return result; 40 | } 41 | } 42 | return 0; 43 | }); 44 | 45 | export const amountToInteger = (n) => Math.round(n * 100); 46 | -------------------------------------------------------------------------------- /src/app-openid.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { 3 | errorMiddleware, 4 | requestLoggerMiddleware, 5 | validateSessionMiddleware, 6 | } from './util/middlewares.js'; 7 | import { disableOpenID, enableOpenID, isAdmin } from './account-db.js'; 8 | import { 9 | isValidRedirectUrl, 10 | loginWithOpenIdFinalize, 11 | } from './accounts/openid.js'; 12 | import * as UserService from './services/user-service.js'; 13 | 14 | let app = express(); 15 | app.use(express.json()); 16 | app.use(express.urlencoded({ extended: true })); 17 | app.use(requestLoggerMiddleware); 18 | export { app as handlers }; 19 | 20 | app.post('/enable', validateSessionMiddleware, async (req, res) => { 21 | if (!isAdmin(res.locals.user_id)) { 22 | res.status(403).send({ 23 | status: 'error', 24 | reason: 'forbidden', 25 | details: 'permission-not-found', 26 | }); 27 | return; 28 | } 29 | 30 | let { error } = (await enableOpenID(req.body)) || {}; 31 | 32 | if (error) { 33 | res.status(500).send({ status: 'error', reason: error }); 34 | return; 35 | } 36 | res.send({ status: 'ok' }); 37 | }); 38 | 39 | app.post('/disable', validateSessionMiddleware, async (req, res) => { 40 | if (!isAdmin(res.locals.user_id)) { 41 | res.status(403).send({ 42 | status: 'error', 43 | reason: 'forbidden', 44 | details: 'permission-not-found', 45 | }); 46 | return; 47 | } 48 | 49 | let { error } = (await disableOpenID(req.body)) || {}; 50 | 51 | if (error) { 52 | res.status(401).send({ status: 'error', reason: error }); 53 | return; 54 | } 55 | res.send({ status: 'ok' }); 56 | }); 57 | 58 | app.get('/config', async (req, res) => { 59 | const { cnt: ownerCount } = UserService.getOwnerCount() || {}; 60 | 61 | if (ownerCount > 0) { 62 | res.status(400).send({ status: 'error', reason: 'already-bootstraped' }); 63 | return; 64 | } 65 | 66 | const auth = UserService.getOpenIDConfig(); 67 | 68 | if (!auth) { 69 | res 70 | .status(500) 71 | .send({ status: 'error', reason: 'OpenID configuration not found' }); 72 | return; 73 | } 74 | 75 | try { 76 | const openIdConfig = JSON.parse(auth.extra_data); 77 | res.send({ openId: openIdConfig }); 78 | } catch (error) { 79 | res 80 | .status(500) 81 | .send({ status: 'error', reason: 'Invalid OpenID configuration' }); 82 | } 83 | }); 84 | 85 | app.get('/callback', async (req, res) => { 86 | let { error, url } = await loginWithOpenIdFinalize(req.query); 87 | 88 | if (error) { 89 | res.status(400).send({ status: 'error', reason: error }); 90 | return; 91 | } 92 | 93 | if (!isValidRedirectUrl(url)) { 94 | res.status(400).send({ status: 'error', reason: 'Invalid redirect URL' }); 95 | return; 96 | } 97 | 98 | res.redirect(url); 99 | }); 100 | 101 | app.use(errorMiddleware); 102 | -------------------------------------------------------------------------------- /src/app-secrets.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { secretsService } from './services/secrets-service.js'; 3 | import getAccountDb, { isAdmin } from './account-db.js'; 4 | import { 5 | requestLoggerMiddleware, 6 | validateSessionMiddleware, 7 | } from './util/middlewares.js'; 8 | 9 | const app = express(); 10 | 11 | export { app as handlers }; 12 | app.use(express.json()); 13 | app.use(requestLoggerMiddleware); 14 | app.use(validateSessionMiddleware); 15 | 16 | app.post('/', async (req, res) => { 17 | let method; 18 | try { 19 | const result = getAccountDb().first( 20 | 'SELECT method FROM auth WHERE active = 1', 21 | ); 22 | method = result?.method; 23 | } catch (error) { 24 | console.error('Failed to fetch auth method:', error); 25 | return res.status(500).send({ 26 | status: 'error', 27 | reason: 'database-error', 28 | details: 'Failed to validate authentication method', 29 | }); 30 | } 31 | const { name, value } = req.body; 32 | 33 | if (method === 'openid') { 34 | let canSaveSecrets = isAdmin(res.locals.user_id); 35 | 36 | if (!canSaveSecrets) { 37 | res.status(403).send({ 38 | status: 'error', 39 | reason: 'not-admin', 40 | details: 'You have to be admin to set secrets', 41 | }); 42 | 43 | return; 44 | } 45 | } 46 | 47 | secretsService.set(name, value); 48 | 49 | res.status(200).send({ status: 'ok' }); 50 | }); 51 | 52 | app.get('/:name', async (req, res) => { 53 | const name = req.params.name; 54 | const keyExists = secretsService.exists(name); 55 | if (keyExists) { 56 | res.sendStatus(204); 57 | } else { 58 | res.status(404).send('key not found'); 59 | } 60 | }); 61 | -------------------------------------------------------------------------------- /src/app-sync/errors.js: -------------------------------------------------------------------------------- 1 | export class FileNotFound extends Error { 2 | constructor(params = {}) { 3 | super("File does not exist or you don't have access to it"); 4 | this.details = params; 5 | } 6 | } 7 | 8 | export class GenericFileError extends Error { 9 | constructor(message, params = {}) { 10 | super(message); 11 | this.details = params; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/app-sync/validation.js: -------------------------------------------------------------------------------- 1 | // This is a version representing the internal format of sync 2 | // messages. When this changes, all sync files need to be reset. We 3 | // will check this version when syncing and notify the user if they 4 | // need to reset. 5 | const SYNC_FORMAT_VERSION = 2; 6 | 7 | const validateSyncedFile = (groupId, keyId, currentFile) => { 8 | if ( 9 | currentFile.syncVersion == null || 10 | currentFile.syncVersion < SYNC_FORMAT_VERSION 11 | ) { 12 | return 'file-old-version'; 13 | } 14 | 15 | // When resetting sync state, something went wrong. There is no 16 | // group id and it's awaiting a file to be uploaded. 17 | if (currentFile.groupId == null) { 18 | return 'file-needs-upload'; 19 | } 20 | 21 | // Check to make sure the uploaded file is valid and has been 22 | // encrypted with the same key it is registered with (this might 23 | // be wrong if there was an error during the key creation 24 | // process) 25 | let uploadedKeyId = currentFile.encryptMeta 26 | ? JSON.parse(currentFile.encryptMeta).keyId 27 | : null; 28 | if (uploadedKeyId !== currentFile.encryptKeyId) { 29 | return 'file-key-mismatch'; 30 | } 31 | 32 | // The changes being synced are part of an old group, which 33 | // means the file has been reset. User needs to re-download. 34 | if (groupId !== currentFile.groupId) { 35 | return 'file-has-reset'; 36 | } 37 | 38 | // The data is encrypted with a different key which is 39 | // unacceptable. We can't accept these changes. Reject them and 40 | // tell the user that they need to generate the correct key 41 | // (which necessitates a sync reset so they need to re-download). 42 | if (keyId !== currentFile.encryptKeyId) { 43 | return 'file-has-new-key'; 44 | } 45 | 46 | return null; 47 | }; 48 | 49 | const validateUploadedFile = (groupId, keyId, currentFile) => { 50 | if (!currentFile) { 51 | // File is new, so no need to validate 52 | return null; 53 | } 54 | // The uploading file is part of an old group, so reject 55 | // it. All of its internal sync state is invalid because its 56 | // old. The sync state has been reset, so user needs to 57 | // either reset again or download from the current group. 58 | if (groupId !== currentFile.groupId) { 59 | return 'file-has-reset'; 60 | } 61 | 62 | // The key that the file is encrypted with is different than 63 | // the current registered key. All data must always be 64 | // encrypted with the registered key for consistency. Key 65 | // changes always necessitate a sync reset, which means this 66 | // upload is trying to overwrite another reset. That might 67 | // be be fine, but since we definitely cannot accept a file 68 | // encrypted with the wrong key, we bail and suggest the 69 | // user download the latest file. 70 | if (keyId !== currentFile.encryptKeyId) { 71 | return 'file-has-new-key'; 72 | } 73 | 74 | return null; 75 | }; 76 | 77 | export { validateSyncedFile, validateUploadedFile }; 78 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import express from 'express'; 3 | import actuator from 'express-actuator'; 4 | import bodyParser from 'body-parser'; 5 | import cors from 'cors'; 6 | import config from './load-config.js'; 7 | import rateLimit from 'express-rate-limit'; 8 | 9 | import * as accountApp from './app-account.js'; 10 | import * as syncApp from './app-sync.js'; 11 | import * as goCardlessApp from './app-gocardless/app-gocardless.js'; 12 | import * as simpleFinApp from './app-simplefin/app-simplefin.js'; 13 | import * as secretApp from './app-secrets.js'; 14 | import * as adminApp from './app-admin.js'; 15 | import * as openidApp from './app-openid.js'; 16 | 17 | const app = express(); 18 | 19 | process.on('unhandledRejection', (reason) => { 20 | console.log('Rejection:', reason); 21 | }); 22 | 23 | app.disable('x-powered-by'); 24 | app.use(cors()); 25 | app.set('trust proxy', config.trustedProxies); 26 | app.use( 27 | rateLimit({ 28 | windowMs: 60 * 1000, 29 | max: 500, 30 | legacyHeaders: false, 31 | standardHeaders: true, 32 | }), 33 | ); 34 | app.use(bodyParser.json({ limit: `${config.upload.fileSizeLimitMB}mb` })); 35 | app.use( 36 | bodyParser.raw({ 37 | type: 'application/actual-sync', 38 | limit: `${config.upload.fileSizeSyncLimitMB}mb`, 39 | }), 40 | ); 41 | app.use( 42 | bodyParser.raw({ 43 | type: 'application/encrypted-file', 44 | limit: `${config.upload.syncEncryptedFileSizeLimitMB}mb`, 45 | }), 46 | ); 47 | 48 | app.use('/sync', syncApp.handlers); 49 | app.use('/account', accountApp.handlers); 50 | app.use('/gocardless', goCardlessApp.handlers); 51 | app.use('/simplefin', simpleFinApp.handlers); 52 | app.use('/secret', secretApp.handlers); 53 | 54 | app.use('/admin', adminApp.handlers); 55 | app.use('/openid', openidApp.handlers); 56 | 57 | app.get('/mode', (req, res) => { 58 | res.send(config.mode); 59 | }); 60 | 61 | app.use(actuator()); // Provides /health, /metrics, /info 62 | 63 | // The web frontend 64 | app.use((req, res, next) => { 65 | res.set('Cross-Origin-Opener-Policy', 'same-origin'); 66 | res.set('Cross-Origin-Embedder-Policy', 'require-corp'); 67 | next(); 68 | }); 69 | app.use(express.static(config.webRoot, { index: false })); 70 | 71 | app.get('/*', (req, res) => res.sendFile(config.webRoot + '/index.html')); 72 | 73 | function parseHTTPSConfig(value) { 74 | if (value.startsWith('-----BEGIN')) { 75 | return value; 76 | } 77 | return fs.readFileSync(value); 78 | } 79 | 80 | export default async function run() { 81 | if (config.https) { 82 | const https = await import('node:https'); 83 | const httpsOptions = { 84 | ...config.https, 85 | key: parseHTTPSConfig(config.https.key), 86 | cert: parseHTTPSConfig(config.https.cert), 87 | }; 88 | https.createServer(httpsOptions, app).listen(config.port, config.hostname); 89 | } else { 90 | app.listen(config.port, config.hostname); 91 | } 92 | 93 | console.log('Listening on ' + config.hostname + ':' + config.port + '...'); 94 | } 95 | -------------------------------------------------------------------------------- /src/config-types.ts: -------------------------------------------------------------------------------- 1 | import { ServerOptions } from 'https'; 2 | 3 | type LoginMethod = 'password' | 'header' | 'openid'; 4 | 5 | export interface Config { 6 | mode: 'test' | 'development'; 7 | loginMethod: LoginMethod; 8 | allowedLoginMethods: LoginMethod[]; 9 | trustedProxies: string[]; 10 | trustedAuthProxies?: string[]; 11 | dataDir: string; 12 | projectRoot: string; 13 | port: number; 14 | hostname: string; 15 | serverFiles: string; 16 | userFiles: string; 17 | webRoot: string; 18 | https?: { 19 | key: string; 20 | cert: string; 21 | } & ServerOptions; 22 | upload?: { 23 | fileSizeSyncLimitMB: number; 24 | syncEncryptedFileSizeLimitMB: number; 25 | fileSizeLimitMB: number; 26 | }; 27 | openId?: { 28 | issuer: 29 | | string 30 | | { 31 | name: string; 32 | authorization_endpoint: string; 33 | token_endpoint: string; 34 | userinfo_endpoint: string; 35 | }; 36 | client_id: string; 37 | client_secret: string; 38 | server_hostname: string; 39 | authMethod?: 'openid' | 'oauth2'; 40 | }; 41 | multiuser: boolean; 42 | token_expiration?: 'never' | 'openid-provider' | number; 43 | } 44 | -------------------------------------------------------------------------------- /src/db.js: -------------------------------------------------------------------------------- 1 | import Database from 'better-sqlite3'; 2 | 3 | class WrappedDatabase { 4 | constructor(db) { 5 | this.db = db; 6 | } 7 | 8 | /** 9 | * @param {string} sql 10 | * @param {string[]} params 11 | */ 12 | all(sql, params = []) { 13 | let stmt = this.db.prepare(sql); 14 | return stmt.all(...params); 15 | } 16 | 17 | /** 18 | * @param {string} sql 19 | * @param {string[]} params 20 | */ 21 | first(sql, params = []) { 22 | let rows = this.all(sql, params); 23 | return rows.length === 0 ? null : rows[0]; 24 | } 25 | 26 | /** 27 | * @param {string} sql 28 | */ 29 | exec(sql) { 30 | return this.db.exec(sql); 31 | } 32 | 33 | /** 34 | * @param {string} sql 35 | * @param {string[]} params 36 | */ 37 | mutate(sql, params = []) { 38 | let stmt = this.db.prepare(sql); 39 | let info = stmt.run(...params); 40 | return { changes: info.changes, insertId: info.lastInsertRowid }; 41 | } 42 | 43 | /** 44 | * @param {() => void} fn 45 | */ 46 | transaction(fn) { 47 | return this.db.transaction(fn)(); 48 | } 49 | 50 | close() { 51 | this.db.close(); 52 | } 53 | } 54 | 55 | /** @param {string} filename */ 56 | export default function openDatabase(filename) { 57 | return new WrappedDatabase(new Database(filename)); 58 | } 59 | -------------------------------------------------------------------------------- /src/migrations.js: -------------------------------------------------------------------------------- 1 | import migrate from 'migrate'; 2 | import path from 'node:path'; 3 | import config from './load-config.js'; 4 | 5 | export default function run(direction = 'up') { 6 | console.log( 7 | `Checking if there are any migrations to run for direction "${direction}"...`, 8 | ); 9 | 10 | return new Promise((resolve) => 11 | migrate.load( 12 | { 13 | stateStore: `${path.join(config.dataDir, '.migrate')}${ 14 | config.mode === 'test' ? '-test' : '' 15 | }`, 16 | migrationsDirectory: `${path.join(config.projectRoot, 'migrations')}`, 17 | }, 18 | (err, set) => { 19 | if (err) { 20 | throw err; 21 | } 22 | 23 | set[direction]((err) => { 24 | if (err) { 25 | throw err; 26 | } 27 | 28 | console.log('Migrations: DONE'); 29 | resolve(); 30 | }); 31 | }, 32 | ), 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/run-migrations.js: -------------------------------------------------------------------------------- 1 | import run from './migrations.js'; 2 | 3 | const direction = process.argv[2] || 'up'; 4 | 5 | run(direction).catch((err) => { 6 | console.error('Migration failed:', err); 7 | process.exit(1); 8 | }); 9 | -------------------------------------------------------------------------------- /src/scripts/disable-openid.js: -------------------------------------------------------------------------------- 1 | import { 2 | disableOpenID, 3 | getActiveLoginMethod, 4 | needsBootstrap, 5 | } from '../account-db.js'; 6 | import { promptPassword } from '../util/prompt.js'; 7 | 8 | if (needsBootstrap()) { 9 | console.log('System needs to be bootstrapped first. OpenID is not enabled.'); 10 | 11 | process.exit(1); 12 | } else { 13 | console.log('To disable OpenID, you have to enter your server password:'); 14 | try { 15 | const loginMethod = getActiveLoginMethod(); 16 | console.log(`Current login method: ${loginMethod}`); 17 | 18 | if (loginMethod === 'password') { 19 | console.log('OpenID already disabled.'); 20 | process.exit(0); 21 | } 22 | 23 | const password = await promptPassword(); 24 | const { error } = (await disableOpenID({ password })) || {}; 25 | 26 | if (error) { 27 | console.log('Error disabling OpenID:', error); 28 | console.log( 29 | 'Please report this as an issue: https://github.com/actualbudget/actual-server/issues', 30 | ); 31 | process.exit(2); 32 | } 33 | console.log('OpenID disabled!'); 34 | console.log( 35 | 'Note: you will need to log in with the password on any browsers or devices that are currently logged in.', 36 | ); 37 | } catch (err) { 38 | console.log('Unexpected error:', err); 39 | console.log( 40 | 'Please report this as an issue: https://github.com/actualbudget/actual-server/issues', 41 | ); 42 | process.exit(2); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/scripts/enable-openid.js: -------------------------------------------------------------------------------- 1 | import { 2 | enableOpenID, 3 | getActiveLoginMethod, 4 | needsBootstrap, 5 | } from '../account-db.js'; 6 | import finalConfig from '../load-config.js'; 7 | 8 | if (needsBootstrap()) { 9 | console.log( 10 | 'It looks like you don’t have a password set yet. Password is the fallback authentication method when using OpenID. Execute the command reset-password before using this command!', 11 | ); 12 | 13 | process.exit(1); 14 | } else { 15 | console.log('Enabling openid based on Environment variables or config.json'); 16 | try { 17 | const loginMethod = getActiveLoginMethod(); 18 | console.log(`Current login method: ${loginMethod}`); 19 | 20 | if (loginMethod === 'openid') { 21 | console.log('OpenID already enabled.'); 22 | process.exit(0); 23 | } 24 | const { error } = (await enableOpenID(finalConfig)) || {}; 25 | 26 | if (error) { 27 | console.log('Error enabling openid:', error); 28 | if (error === 'invalid-login-settings') { 29 | console.log( 30 | 'Error configuring OpenID. Please verify that the configuration file or environment variables are correct.', 31 | ); 32 | 33 | process.exit(1); 34 | } else { 35 | console.log( 36 | 'Please report this as an issue: https://github.com/actualbudget/actual-server/issues', 37 | ); 38 | 39 | process.exit(2); 40 | } 41 | } 42 | console.log('OpenID enabled!'); 43 | console.log( 44 | 'Note: The first user to login with OpenID will be the owner of the server.', 45 | ); 46 | } catch (err) { 47 | console.log('Unexpected error:', err); 48 | console.log( 49 | 'Please report this as an issue: https://github.com/actualbudget/actual-server/issues', 50 | ); 51 | process.exit(2); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/scripts/health-check.js: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import config from '../load-config.js'; 3 | 4 | let protocol = config.https ? 'https' : 'http'; 5 | let hostname = config.hostname === '::' ? 'localhost' : config.hostname; 6 | 7 | fetch(`${protocol}://${hostname}:${config.port}/health`) 8 | .then((res) => res.json()) 9 | .then((res) => { 10 | if (res.status !== 'UP') { 11 | throw new Error( 12 | 'Health check failed: Server responded to health check with status ' + 13 | res.status, 14 | ); 15 | } 16 | }) 17 | .catch((err) => { 18 | console.log('Health check failed:', err); 19 | process.exit(1); 20 | }); 21 | -------------------------------------------------------------------------------- /src/scripts/reset-password.js: -------------------------------------------------------------------------------- 1 | import { bootstrap, needsBootstrap } from '../account-db.js'; 2 | import { changePassword } from '../accounts/password.js'; 3 | import { promptPassword } from '../util/prompt.js'; 4 | 5 | if (needsBootstrap()) { 6 | console.log( 7 | 'It looks like you don’t have a password set yet. Let’s set one up now!', 8 | ); 9 | 10 | try { 11 | const password = await promptPassword(); 12 | const { error } = await bootstrap({ password }); 13 | if (error) { 14 | console.log('Error setting password:', error); 15 | console.log( 16 | 'Please report this as an issue: https://github.com/actualbudget/actual-server/issues', 17 | ); 18 | process.exit(1); 19 | } 20 | console.log('Password set!'); 21 | } catch (err) { 22 | console.log('Unexpected error:', err); 23 | console.log( 24 | 'Please report this as an issue: https://github.com/actualbudget/actual-server/issues', 25 | ); 26 | process.exit(1); 27 | } 28 | } else { 29 | console.log('It looks like you already have a password set. Let’s reset it!'); 30 | try { 31 | const password = await promptPassword(); 32 | const { error } = await changePassword(password); 33 | if (error) { 34 | console.log('Error changing password:', error); 35 | console.log( 36 | 'Please report this as an issue: https://github.com/actualbudget/actual-server/issues', 37 | ); 38 | process.exit(1); 39 | } 40 | console.log('Password changed!'); 41 | console.log( 42 | 'Note: you will need to log in with the new password on any browsers or devices that are currently logged in.', 43 | ); 44 | } catch (err) { 45 | console.log('Unexpected error:', err); 46 | console.log( 47 | 'Please report this as an issue: https://github.com/actualbudget/actual-server/issues', 48 | ); 49 | process.exit(1); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/secrets.test.js: -------------------------------------------------------------------------------- 1 | import { secretsService } from './services/secrets-service.js'; 2 | import request from 'supertest'; 3 | import { handlers as app } from './app-secrets.js'; 4 | describe('secretsService', () => { 5 | const testSecretName = 'testSecret'; 6 | const testSecretValue = 'testValue'; 7 | 8 | it('should set a secret', () => { 9 | const result = secretsService.set(testSecretName, testSecretValue); 10 | expect(result).toBeDefined(); 11 | expect(result.changes).toBe(1); 12 | }); 13 | 14 | it('should get a secret', () => { 15 | const result = secretsService.get(testSecretName); 16 | expect(result).toBeDefined(); 17 | expect(result).toBe(testSecretValue); 18 | }); 19 | 20 | it('should check if a secret exists', () => { 21 | const exists = secretsService.exists(testSecretName); 22 | expect(exists).toBe(true); 23 | 24 | const nonExistent = secretsService.exists('nonExistentSecret'); 25 | expect(nonExistent).toBe(false); 26 | }); 27 | 28 | it('should update a secret', () => { 29 | const newValue = 'newValue'; 30 | const setResult = secretsService.set(testSecretName, newValue); 31 | expect(setResult).toBeDefined(); 32 | expect(setResult.changes).toBe(1); 33 | 34 | const getResult = secretsService.get(testSecretName); 35 | expect(getResult).toBeDefined(); 36 | expect(getResult).toBe(newValue); 37 | }); 38 | 39 | describe('secrets api', () => { 40 | it('returns 401 if the user is not authenticated', async () => { 41 | secretsService.set(testSecretName, testSecretValue); 42 | const res = await request(app).get(`/${testSecretName}`); 43 | 44 | expect(res.statusCode).toEqual(401); 45 | expect(res.body).toEqual({ 46 | details: 'token-not-found', 47 | reason: 'unauthorized', 48 | status: 'error', 49 | }); 50 | }); 51 | 52 | it('returns 404 if secret does not exist', async () => { 53 | const res = await request(app) 54 | .get(`/thiskeydoesnotexist`) 55 | .set('x-actual-token', 'valid-token'); 56 | 57 | expect(res.statusCode).toEqual(404); 58 | }); 59 | 60 | it('returns 204 if secret exists', async () => { 61 | secretsService.set(testSecretName, testSecretValue); 62 | const res = await request(app) 63 | .get(`/${testSecretName}`) 64 | .set('x-actual-token', 'valid-token'); 65 | 66 | expect(res.statusCode).toEqual(204); 67 | }); 68 | 69 | it('returns 200 if secret was set', async () => { 70 | secretsService.set(testSecretName, testSecretValue); 71 | const res = await request(app) 72 | .post(`/`) 73 | .set('x-actual-token', 'valid-token') 74 | .send({ name: testSecretName, value: testSecretValue }); 75 | 76 | expect(res.statusCode).toEqual(200); 77 | expect(res.body).toEqual({ 78 | status: 'ok', 79 | }); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/services/secrets-service.js: -------------------------------------------------------------------------------- 1 | import createDebug from 'debug'; 2 | import getAccountDb from '../account-db.js'; 3 | 4 | /** 5 | * An enum of valid secret names. 6 | * @readonly 7 | * @enum {string} 8 | */ 9 | export const SecretName = { 10 | gocardless_secretId: 'gocardless_secretId', 11 | gocardless_secretKey: 'gocardless_secretKey', 12 | simplefin_token: 'simplefin_token', 13 | simplefin_accessKey: 'simplefin_accessKey', 14 | }; 15 | 16 | class SecretsDb { 17 | constructor() { 18 | this.debug = createDebug('actual:secrets-db'); 19 | this.db = null; 20 | } 21 | 22 | open() { 23 | return getAccountDb(); 24 | } 25 | 26 | set(name, value) { 27 | if (!this.db) { 28 | this.db = this.open(); 29 | } 30 | 31 | this.debug(`setting secret '${name}' to '${value}'`); 32 | const result = this.db.mutate( 33 | `INSERT OR REPLACE INTO secrets (name, value) VALUES (?,?)`, 34 | [name, value], 35 | ); 36 | return result; 37 | } 38 | 39 | get(name) { 40 | if (!this.db) { 41 | this.db = this.open(); 42 | } 43 | 44 | this.debug(`getting secret '${name}'`); 45 | const result = this.db.first(`SELECT value FROM secrets WHERE name =?`, [ 46 | name, 47 | ]); 48 | return result; 49 | } 50 | } 51 | 52 | const secretsDb = new SecretsDb(); 53 | const _cachedSecrets = new Map(); 54 | /** 55 | * A service for managing secrets stored in `secretsDb`. 56 | */ 57 | export const secretsService = { 58 | /** 59 | * Retrieves the value of a secret by name. 60 | * @param {SecretName} name - The name of the secret to retrieve. 61 | * @returns {string|null} The value of the secret, or null if the secret does not exist. 62 | */ 63 | get: (name) => { 64 | return _cachedSecrets.get(name) ?? secretsDb.get(name)?.value ?? null; 65 | }, 66 | 67 | /** 68 | * Sets the value of a secret by name. 69 | * @param {SecretName} name - The name of the secret to set. 70 | * @param {string} value - The value to set for the secret. 71 | * @returns {Object} 72 | */ 73 | set: (name, value) => { 74 | const result = secretsDb.set(name, value); 75 | 76 | if (result.changes === 1) { 77 | _cachedSecrets.set(name, value); 78 | } 79 | return result; 80 | }, 81 | 82 | /** 83 | * Determines whether a secret with the given name exists. 84 | * @param {SecretName} name - The name of the secret to check for existence. 85 | * @returns {boolean} True if a secret with the given name exists, false otherwise. 86 | */ 87 | exists: (name) => { 88 | return Boolean(secretsService.get(name)); 89 | }, 90 | }; 91 | -------------------------------------------------------------------------------- /src/sql/messages.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE TABLE messages_binary 3 | (timestamp TEXT PRIMARY KEY, 4 | is_encrypted BOOLEAN, 5 | content bytea); 6 | 7 | CREATE TABLE messages_merkles 8 | (id INTEGER PRIMARY KEY, 9 | merkle TEXT); 10 | -------------------------------------------------------------------------------- /src/sync-simple.js: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync } from 'node:fs'; 2 | import { join } from 'node:path'; 3 | import openDatabase from './db.js'; 4 | import { getPathForGroupFile } from './util/paths.js'; 5 | 6 | import { sqlDir } from './load-config.js'; 7 | 8 | import { merkle, SyncProtoBuf, Timestamp } from '@actual-app/crdt'; 9 | 10 | function getGroupDb(groupId) { 11 | let path = getPathForGroupFile(groupId); 12 | let needsInit = !existsSync(path); 13 | 14 | let db = openDatabase(path); 15 | 16 | if (needsInit) { 17 | let sql = readFileSync(join(sqlDir, 'messages.sql'), 'utf8'); 18 | db.exec(sql); 19 | } 20 | 21 | return db; 22 | } 23 | 24 | function addMessages(db, messages) { 25 | let returnValue; 26 | db.transaction(() => { 27 | let trie = getMerkle(db); 28 | 29 | if (messages.length > 0) { 30 | for (let msg of messages) { 31 | let info = db.mutate( 32 | `INSERT OR IGNORE INTO messages_binary (timestamp, is_encrypted, content) 33 | VALUES (?, ?, ?)`, 34 | [ 35 | msg.getTimestamp(), 36 | msg.getIsencrypted() ? 1 : 0, 37 | Buffer.from(msg.getContent()), 38 | ], 39 | ); 40 | 41 | if (info.changes > 0) { 42 | trie = merkle.insert(trie, Timestamp.parse(msg.getTimestamp())); 43 | } 44 | } 45 | } 46 | 47 | trie = merkle.prune(trie); 48 | 49 | db.mutate( 50 | 'INSERT INTO messages_merkles (id, merkle) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET merkle = ?', 51 | [JSON.stringify(trie), JSON.stringify(trie)], 52 | ); 53 | 54 | returnValue = trie; 55 | }); 56 | 57 | return returnValue; 58 | } 59 | 60 | function getMerkle(db) { 61 | let rows = db.all('SELECT * FROM messages_merkles'); 62 | 63 | if (rows.length > 0) { 64 | return JSON.parse(rows[0].merkle); 65 | } else { 66 | // No merkle trie exists yet (first sync of the app), so create a 67 | // default one. 68 | return {}; 69 | } 70 | } 71 | 72 | export function sync(messages, since, groupId) { 73 | let db = getGroupDb(groupId); 74 | let newMessages = db.all( 75 | `SELECT * FROM messages_binary 76 | WHERE timestamp > ? 77 | ORDER BY timestamp`, 78 | [since], 79 | ); 80 | 81 | let trie = addMessages(db, messages); 82 | 83 | db.close(); 84 | 85 | return { 86 | trie, 87 | newMessages: newMessages.map((msg) => { 88 | const envelopePb = new SyncProtoBuf.MessageEnvelope(); 89 | envelopePb.setTimestamp(msg.timestamp); 90 | envelopePb.setIsencrypted(msg.is_encrypted); 91 | envelopePb.setContent(msg.content); 92 | return envelopePb; 93 | }), 94 | }; 95 | } 96 | -------------------------------------------------------------------------------- /src/util/hash.js: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | 3 | export async function sha256String(str) { 4 | return crypto.createHash('sha256').update(str).digest('base64'); 5 | } 6 | -------------------------------------------------------------------------------- /src/util/middlewares.js: -------------------------------------------------------------------------------- 1 | import validateSession from './validate-user.js'; 2 | 3 | import * as winston from 'winston'; 4 | import * as expressWinston from 'express-winston'; 5 | 6 | /** 7 | * @param {Error} err 8 | * @param {import('express').Request} req 9 | * @param {import('express').Response} res 10 | * @param {import('express').NextFunction} next 11 | */ 12 | async function errorMiddleware(err, req, res, next) { 13 | if (res.headersSent) { 14 | // If you call next() with an error after you have started writing the response 15 | // (for example, if you encounter an error while streaming the response 16 | // to the client), the Express default error handler closes 17 | // the connection and fails the request. 18 | 19 | // So when you add a custom error handler, you must delegate 20 | // to the default Express error handler, when the headers 21 | // have already been sent to the client 22 | // Source: https://expressjs.com/en/guide/error-handling.html 23 | return next(err); 24 | } 25 | console.log(`Error on endpoint ${req.url}`, err.message, err.stack); 26 | res.status(500).send({ status: 'error', reason: 'internal-error' }); 27 | } 28 | 29 | /** 30 | * @param {import('express').Request} req 31 | * @param {import('express').Response} res 32 | * @param {import('express').NextFunction} next 33 | */ 34 | const validateSessionMiddleware = async (req, res, next) => { 35 | let session = await validateSession(req, res); 36 | if (!session) { 37 | return; 38 | } 39 | 40 | res.locals = session; 41 | next(); 42 | }; 43 | 44 | const requestLoggerMiddleware = expressWinston.logger({ 45 | transports: [new winston.transports.Console()], 46 | format: winston.format.combine( 47 | winston.format.colorize(), 48 | winston.format.timestamp(), 49 | winston.format.printf((args) => { 50 | const { timestamp, level, meta } = args; 51 | const { res, req } = meta; 52 | 53 | return `${timestamp} ${level}: ${req.method} ${res.statusCode} ${req.url}`; 54 | }), 55 | ), 56 | }); 57 | 58 | export { validateSessionMiddleware, errorMiddleware, requestLoggerMiddleware }; 59 | -------------------------------------------------------------------------------- /src/util/paths.js: -------------------------------------------------------------------------------- 1 | import { join } from 'node:path'; 2 | import config from '../load-config.js'; 3 | 4 | /** @param {string} fileId */ 5 | export function getPathForUserFile(fileId) { 6 | return join(config.userFiles, `file-${fileId}.blob`); 7 | } 8 | 9 | /** @param {string} groupId */ 10 | export function getPathForGroupFile(groupId) { 11 | return join(config.userFiles, `group-${groupId}.sqlite`); 12 | } 13 | -------------------------------------------------------------------------------- /src/util/payee-name.js: -------------------------------------------------------------------------------- 1 | import { title } from './title/index.js'; 2 | 3 | function formatPayeeIban(iban) { 4 | return '(' + iban.slice(0, 4) + ' XXX ' + iban.slice(-4) + ')'; 5 | } 6 | 7 | export const formatPayeeName = (trans) => { 8 | const amount = trans.transactionAmount.amount; 9 | const nameParts = []; 10 | 11 | // get the correct name and account fields for the transaction amount 12 | let name; 13 | let account; 14 | if (amount > 0 || Object.is(Number(amount), 0)) { 15 | name = trans.debtorName; 16 | account = trans.debtorAccount; 17 | } else { 18 | name = trans.creditorName; 19 | account = trans.creditorAccount; 20 | } 21 | 22 | // use the correct name field if it was found 23 | // if not, use whatever we can find 24 | 25 | // if the primary name option is set, prevent the account from falling back 26 | account = name ? account : trans.debtorAccount || trans.creditorAccount; 27 | 28 | name = 29 | name || 30 | trans.debtorName || 31 | trans.creditorName || 32 | trans.remittanceInformationUnstructured || 33 | (trans.remittanceInformationUnstructuredArray || []).join(', ') || 34 | trans.additionalInformation; 35 | 36 | if (name) { 37 | nameParts.push(title(name)); 38 | } 39 | 40 | if (account && account.iban) { 41 | nameParts.push(formatPayeeIban(account.iban)); 42 | } 43 | 44 | return nameParts.join(' '); 45 | }; 46 | -------------------------------------------------------------------------------- /src/util/prompt.js: -------------------------------------------------------------------------------- 1 | import { createInterface, cursorTo } from 'node:readline'; 2 | 3 | export async function prompt(message) { 4 | let rl = createInterface({ 5 | input: process.stdin, 6 | output: process.stdout, 7 | }); 8 | 9 | let promise = new Promise((resolve) => { 10 | rl.question(message, (answer) => { 11 | resolve(answer); 12 | rl.close(); 13 | }); 14 | }); 15 | 16 | let answer = await promise; 17 | 18 | return answer; 19 | } 20 | 21 | export async function promptPassword() { 22 | let password = await askForPassword('Enter a password, then press enter: '); 23 | 24 | if (password === '') { 25 | console.log('Password cannot be empty.'); 26 | return promptPassword(); 27 | } 28 | 29 | let password2 = await askForPassword( 30 | 'Enter the password again, then press enter: ', 31 | ); 32 | 33 | if (password !== password2) { 34 | console.log('Passwords do not match.'); 35 | return promptPassword(); 36 | } 37 | 38 | return password; 39 | } 40 | 41 | async function askForPassword(prompt) { 42 | let dataListener, endListener; 43 | 44 | let promise = new Promise((resolve) => { 45 | let result = ''; 46 | process.stdout.write(prompt); 47 | process.stdin.setRawMode(true); 48 | process.stdin.resume(); 49 | dataListener = (key) => { 50 | switch (key[0]) { 51 | case 0x03: // ^C 52 | process.exit(); 53 | break; 54 | case 0x0d: // Enter 55 | process.stdin.setRawMode(false); 56 | process.stdin.pause(); 57 | resolve(result); 58 | break; 59 | case 0x7f: // Backspace 60 | case 0x08: // Delete 61 | if (result) { 62 | result = result.slice(0, -1); 63 | cursorTo(process.stdout, prompt.length + result.length); 64 | process.stdout.write(' '); 65 | cursorTo(process.stdout, prompt.length + result.length); 66 | } 67 | break; 68 | default: 69 | result += key; 70 | process.stdout.write('*'); 71 | break; 72 | } 73 | }; 74 | process.stdin.on('data', dataListener); 75 | 76 | endListener = () => resolve(result); 77 | process.stdin.on('end', endListener); 78 | }); 79 | 80 | let answer = await promise; 81 | 82 | process.stdin.off('data', dataListener); 83 | process.stdin.off('end', endListener); 84 | 85 | process.stdout.write('\n'); 86 | 87 | return answer; 88 | } 89 | -------------------------------------------------------------------------------- /src/util/title/lower-case.js: -------------------------------------------------------------------------------- 1 | const conjunctions = [ 2 | 'for', // 3 | 'and', 4 | 'nor', 5 | 'but', 6 | 'or', 7 | 'yet', 8 | 'so', 9 | ]; 10 | 11 | const articles = [ 12 | 'a', // 13 | 'an', 14 | 'the', 15 | ]; 16 | 17 | const prepositions = [ 18 | 'aboard', 19 | 'about', 20 | 'above', 21 | 'across', 22 | 'after', 23 | 'against', 24 | 'along', 25 | 'amid', 26 | 'among', 27 | 'anti', 28 | 'around', 29 | 'as', 30 | 'at', 31 | 'before', 32 | 'behind', 33 | 'below', 34 | 'beneath', 35 | 'beside', 36 | 'besides', 37 | 'between', 38 | 'beyond', 39 | 'but', 40 | 'by', 41 | 'concerning', 42 | 'considering', 43 | 'despite', 44 | 'down', 45 | 'during', 46 | 'except', 47 | 'excepting', 48 | 'excluding', 49 | 'following', 50 | 'for', 51 | 'from', 52 | 'in', 53 | 'inside', 54 | 'into', 55 | 'like', 56 | 'minus', 57 | 'near', 58 | 'of', 59 | 'off', 60 | 'on', 61 | 'onto', 62 | 'opposite', 63 | 'over', 64 | 'past', 65 | 'per', 66 | 'plus', 67 | 'regarding', 68 | 'round', 69 | 'save', 70 | 'since', 71 | 'than', 72 | 'through', 73 | 'to', 74 | 'toward', 75 | 'towards', 76 | 'under', 77 | 'underneath', 78 | 'unlike', 79 | 'until', 80 | 'up', 81 | 'upon', 82 | 'versus', 83 | 'via', 84 | 'with', 85 | 'within', 86 | 'without', 87 | ]; 88 | 89 | export const lowerCaseSet = new Set([ 90 | ...conjunctions, 91 | ...articles, 92 | ...prepositions, 93 | ]); 94 | -------------------------------------------------------------------------------- /src/util/title/specials.js: -------------------------------------------------------------------------------- 1 | export const specials = [ 2 | 'CLI', 3 | 'API', 4 | 'HTTP', 5 | 'HTTPS', 6 | 'JSX', 7 | 'DNS', 8 | 'URL', 9 | 'CI', 10 | 'CDN', 11 | 'GitHub', 12 | 'CSS', 13 | 'JS', 14 | 'JavaScript', 15 | 'TypeScript', 16 | 'HTML', 17 | 'WordPress', 18 | 'JavaScript', 19 | 'Next.js', 20 | 'Node.js', 21 | ]; 22 | -------------------------------------------------------------------------------- /src/util/validate-user.js: -------------------------------------------------------------------------------- 1 | import config from '../load-config.js'; 2 | import ipaddr from 'ipaddr.js'; 3 | import { getSession } from '../account-db.js'; 4 | 5 | export const TOKEN_EXPIRATION_NEVER = -1; 6 | const MS_PER_SECOND = 1000; 7 | 8 | /** 9 | * @param {import('express').Request} req 10 | * @param {import('express').Response} res 11 | */ 12 | export default function validateSession(req, res) { 13 | let { token } = req.body || {}; 14 | 15 | if (!token) { 16 | token = req.headers['x-actual-token']; 17 | } 18 | 19 | let session = getSession(token); 20 | 21 | if (!session) { 22 | res.status(401); 23 | res.send({ 24 | status: 'error', 25 | reason: 'unauthorized', 26 | details: 'token-not-found', 27 | }); 28 | return null; 29 | } 30 | 31 | if ( 32 | session.expires_at !== TOKEN_EXPIRATION_NEVER && 33 | session.expires_at * MS_PER_SECOND <= Date.now() 34 | ) { 35 | res.status(401); 36 | res.send({ 37 | status: 'error', 38 | reason: 'token-expired', 39 | }); 40 | return null; 41 | } 42 | 43 | return session; 44 | } 45 | 46 | export function validateAuthHeader(req) { 47 | // fallback to trustedProxies when trustedAuthProxies not set 48 | const trustedAuthProxies = config.trustedAuthProxies ?? config.trustedProxies; 49 | // ensure the first hop from our server is trusted 50 | let peer = req.socket.remoteAddress; 51 | let peerIp = ipaddr.process(peer); 52 | const rangeList = { 53 | allowed_ips: trustedAuthProxies.map((q) => ipaddr.parseCIDR(q)), 54 | }; 55 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 56 | // @ts-ignore : there is an error in the ts definition for the function, but this is valid 57 | var matched = ipaddr.subnetMatch(peerIp, rangeList, 'fail'); 58 | /* eslint-enable @typescript-eslint/ban-ts-comment */ 59 | if (matched == 'allowed_ips') { 60 | console.info(`Header Auth Login permitted from ${peer}`); 61 | return true; 62 | } else { 63 | console.warn(`Header Auth Login attempted from ${peer}`); 64 | return false; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | // DOM for URL global in Node 16+ 5 | "lib": ["ES2021"], 6 | "allowSyntheticDefaultImports": true, 7 | "esModuleInterop": true, 8 | "experimentalDecorators": true, 9 | "resolveJsonModule": true, 10 | "downlevelIteration": true, 11 | "skipLibCheck": true, 12 | "jsx": "preserve", 13 | // Check JS files too 14 | "allowJs": true, 15 | "checkJs": true, 16 | "moduleResolution": "node16", 17 | "module": "node16", 18 | "outDir": "build" 19 | }, 20 | "include": ["src/**/*.js", "types/global.d.ts"], 21 | "exclude": ["node_modules", "build", "./app-plaid.js", "coverage"], 22 | } 23 | -------------------------------------------------------------------------------- /upcoming-release-notes/557.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Maintenance 3 | authors: [matt-fidd] 4 | --- 5 | 6 | Dynamically load GoCardless handlers 7 | -------------------------------------------------------------------------------- /upcoming-release-notes/560.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Maintenance 3 | authors: [MikesGlitch] 4 | --- 5 | 6 | Updating readme regarding the consolidation of the Actual-Server and Actual repos 7 | -------------------------------------------------------------------------------- /upcoming-release-notes/566.md: -------------------------------------------------------------------------------- 1 | --- 2 | category: Bugfix 3 | authors: [MikesGlitch] 4 | --- 5 | 6 | Fix ESM bug on Windows when loading gocardless banks 7 | -------------------------------------------------------------------------------- /upcoming-release-notes/README.md: -------------------------------------------------------------------------------- 1 | See the [Writing Good Release Notes](https://actualbudget.org/docs/contributing/#writing-good-release-notes) section of the documentation for more information on how to create a release notes file. 2 | --------------------------------------------------------------------------------