├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── format_request.md └── workflows │ └── test-parsers.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── lerna.json ├── netlify.toml ├── package.json ├── packages ├── ynap-bank2ynab-converter │ ├── .gitignore │ ├── .npmignore │ ├── package.json │ ├── src │ │ ├── index.ts │ │ └── parserconfig.ts │ ├── tsconfig.json │ └── yarn.lock ├── ynap-parsers │ ├── .gitignore │ ├── .npmignore │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── src │ │ ├── at │ │ │ └── ing │ │ │ │ ├── ing-austria.spec.ts │ │ │ │ ├── ing-austria.ts │ │ │ │ └── test-data │ │ │ │ └── ING_Umsaetze.csv │ │ ├── bank2ynab │ │ │ ├── bank2ynab.spec.ts │ │ │ ├── bank2ynab.ts │ │ │ ├── banks.json │ │ │ ├── parse-date.spec.ts │ │ │ ├── parse-date.ts │ │ │ └── test-data │ │ │ │ ├── 2018-04-14_10-52-13_bunq-transactieoverzicht.csv │ │ │ │ ├── 20180226_12345678.csv │ │ │ │ ├── 2019-03-02_11-50-46_bunq-statement.csv │ │ │ │ ├── CSV_A_20180414_112204.csv │ │ │ │ ├── MonzoDataExport_February2018_2018-02-26_174335.csv │ │ │ │ ├── Movements_1234512345_201805271848.csv │ │ │ │ ├── TH_20180101-20180523_page_1.csv │ │ │ │ ├── TH_20180101-20180523_strana_1.csv │ │ │ │ ├── TH_20180521-20180523.csv │ │ │ │ ├── dba33fceecd62c3c727893361e0ba4d3.P000000027355791.csv │ │ │ │ └── export_BE11123456789012_20180304_1422.csv │ │ ├── de │ │ │ ├── 1822direkt │ │ │ │ ├── 1822direkt.spec.ts │ │ │ │ ├── 1822direkt.ts │ │ │ │ └── test-data │ │ │ │ │ └── umsaetze-12345678-25.03.2020_11_45.csv │ │ │ ├── comdirect │ │ │ │ ├── comdirect.spec.ts │ │ │ │ ├── comdirect.ts │ │ │ │ └── test-data │ │ │ │ │ └── umsaetze_1182395341_20190403-2324.csv │ │ │ ├── ing-diba │ │ │ │ ├── ing-diba.spec.ts │ │ │ │ ├── ing-diba.ts │ │ │ │ └── test-data │ │ │ │ │ └── Umsatzanzeige_DE00000105170000890000_20190403.csv │ │ │ ├── kontist │ │ │ │ ├── kontist.spec.ts │ │ │ │ ├── kontist.ts │ │ │ │ └── test-data │ │ │ │ │ └── transactions.csv │ │ │ ├── n26 │ │ │ │ ├── n26.spec.ts │ │ │ │ ├── n26.ts │ │ │ │ └── test-data │ │ │ │ │ ├── n26-csv-transactions-2021.csv │ │ │ │ │ └── n26-csv-transactions-2022.csv │ │ │ ├── outbank │ │ │ │ ├── blz.json │ │ │ │ ├── outbank.spec.ts │ │ │ │ ├── outbank.ts │ │ │ │ └── test-data │ │ │ │ │ └── Outbank_Export_20190403.csv │ │ │ └── volksbank-eg │ │ │ │ ├── test-data │ │ │ │ └── Umsaetze_test_2019.06.19.csv │ │ │ │ ├── volksbank-eg.spec.ts │ │ │ │ └── volksbank-eg.ts │ │ ├── gr │ │ │ └── piraeus │ │ │ │ ├── piraeus.spec.ts │ │ │ │ ├── piraeus.ts │ │ │ │ └── test-data │ │ │ │ ├── Account.Transactions_20190601.xlsx │ │ │ │ └── Account.Transactions_20200725.xlsx │ │ ├── index.spec.ts │ │ ├── index.ts │ │ ├── international │ │ │ ├── mt940 │ │ │ │ ├── mt940.spec.ts │ │ │ │ ├── mt940.ts │ │ │ │ └── test-data │ │ │ │ │ └── mt940-bunq.sta │ │ │ └── revolut │ │ │ │ ├── revolut.spec.ts │ │ │ │ └── revolut.ts │ │ ├── mx │ │ │ └── bbva-bancomer │ │ │ │ ├── bbva-bancomer.spec.ts │ │ │ │ └── bbva-bancomer.ts │ │ ├── pl │ │ │ ├── bank-pocztowy │ │ │ │ ├── bank-pocztowy.spec.ts │ │ │ │ ├── bank-pocztowy.ts │ │ │ │ └── test-data │ │ │ │ │ └── 1571076127593.csv │ │ │ └── mbank │ │ │ │ ├── mbank.spec.ts │ │ │ │ ├── mbank.ts │ │ │ │ └── test-data │ │ │ │ └── operations_190710_191010_201910100004038185.csv │ │ ├── se │ │ │ ├── seb-privat │ │ │ │ ├── seb.spec.ts │ │ │ │ ├── seb.ts │ │ │ │ └── test-data │ │ │ │ │ └── kontoutdrag.xlsx │ │ │ └── sparbanken-tanum │ │ │ │ ├── 2018 │ │ │ │ ├── sparbanken-tanum.spec.ts │ │ │ │ ├── sparbanken-tanum.ts │ │ │ │ └── test-data │ │ │ │ │ └── export.csv │ │ │ │ └── 2019 │ │ │ │ ├── sparbanken-tanum.spec.ts │ │ │ │ ├── sparbanken-tanum.ts │ │ │ │ └── test-data │ │ │ │ └── Transaktioner_2019-10-12_14-57-29.csv │ │ ├── uk │ │ │ ├── aqua │ │ │ │ ├── aqua.spec.ts │ │ │ │ ├── aqua.ts │ │ │ │ └── test-data │ │ │ │ │ └── transactions.csv │ │ │ └── marcus │ │ │ │ ├── marcus.spec.ts │ │ │ │ ├── marcus.ts │ │ │ │ └── test-data │ │ │ │ └── Transactions [Account Number] 2019-06-12 13_40.csv │ │ └── util │ │ │ ├── jschardet.d.ts │ │ │ ├── papaparse.ts │ │ │ ├── read-encoded-file.ts │ │ │ └── read-to-buffer.ts │ ├── tsconfig.json │ └── yarn.lock └── ynap-web-app │ ├── .gitignore │ ├── README.md │ ├── gatsby-browser.js │ ├── gatsby-config.js │ ├── package.json │ ├── src │ ├── components │ │ ├── github-badge.tsx │ │ └── meta-tags.tsx │ ├── pages │ │ ├── 404.tsx │ │ ├── index.tsx │ │ └── supported-formats.tsx │ ├── styles │ │ └── index.css │ └── util │ │ ├── countries-map.json │ │ └── countries.ts │ ├── static │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── manifest.json │ ├── meta-image.jpg │ ├── mstile-150x150.png │ └── safari-pinned-tab.svg │ ├── tsconfig.json │ └── yarn.lock ├── renovate.json └── yarn.lock /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/format_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Format request 3 | about: Request a new CSV format 4 | title: Format request 5 | labels: format-request 6 | assignees: leolabs 7 | --- 8 | 9 | 16 | -------------------------------------------------------------------------------- /.github/workflows/test-parsers.yml: -------------------------------------------------------------------------------- 1 | name: Test Parsers 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [16, 18] 12 | 13 | steps: 14 | - uses: actions/checkout@v3.5.2 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v3.6.0 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: Install, Build, and Test 20 | run: | 21 | cd packages/ynap-parsers 22 | yarn install 23 | yarn build 24 | yarn test --coverage 25 | env: 26 | CI: true 27 | - name: Upload coverage to Codecov 28 | uses: codecov/codecov-action@v3.1.3 29 | with: 30 | token: ${{ secrets.CODECOV_TOKEN }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | # misc 4 | .DS_Store 5 | .env.local 6 | .env.development.local 7 | .env.test.local 8 | .env.production.local 9 | 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 85, 3 | "semi": true, 4 | "singleQuote": true, 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Leo Bernard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # You Need A Parser 2 | 3 | [![codecov](https://codecov.io/gh/leolabs/you-need-a-parser/branch/master/graph/badge.svg)](https://codecov.io/gh/leolabs/you-need-a-parser) 4 | 5 | [Web App](https://ynap.leolabs.org) | [Supported Formats](https://ynap.leolabs.org/supported-formats/) | [Suggest a Format](https://github.com/leolabs/you-need-a-parser/issues/new?template=format_request.md) 6 | 7 | YNAP is a web app that converts CSV files from a variety of sources into a format 8 | that can easily be imported into [You Need A Budget](https://youneedabudget.com). 9 | Just drag the files you want to convert into this window. As the conversion happens 10 | entirely in JS, your files will never leave your browser. 11 | 12 | This repository consists of three packages: 13 | 14 | ### [ynap-parsers](https://github.com/leolabs/you-need-a-parser/tree/master/packages/ynap-parsers) 15 | 16 | This package contains all parsers for different formats. If you want to implement a 17 | new parser, this is the way to go. This package is also available on NPM if you want 18 | to use it in your own projects. 19 | 20 | ### [ynap-web-app](https://github.com/leolabs/you-need-a-parser/tree/master/packages/ynap-web-app) 21 | 22 | This is the web frontend you see at [ynap.leolabs.org](https://ynap.leolabs.org). 23 | 24 | ### [ynap-bank2ynab-converter](https://github.com/leolabs/you-need-a-parser/tree/master/packages/ynap-bank2ynab-converter) 25 | 26 | This tool fetches the current configuration file from [bank2ynab](https://github.com/bank2ynab/bank2ynab) 27 | and converts it to a JSON file that can be read by ynap-parsers. This allows 28 | ynap-parsers to support most of the banks supported by bank2ynab. 29 | 30 | ## Contributing 31 | 32 | If you want to improve YNAP, feel free to submit an issue or open a pull request. 33 | 34 | ## License 35 | 36 | This repo and all included packages are licensed under the 37 | [MIT License](https://choosealicense.com/licenses/mit/). 38 | 39 | If you use any of the packages in this repo in your project, a mention or link 40 | to this repo would be nice but is not required. 41 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "command": { 6 | "version": { 7 | "message": ":bookmark: publish %s" 8 | } 9 | }, 10 | "version": "1.15.0", 11 | "npmClient": "yarn" 12 | } 13 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | base = "/packages/ynap-web-app/" 3 | publish = "/packages/ynap-web-app/public/" 4 | command = "yarn build" 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "you-need-a-parser", 3 | "private": true, 4 | "devDependencies": { 5 | "lerna": "3.22.1" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/ynap-bank2ynab-converter/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | 4 | /*.js 5 | /*.ts 6 | 7 | bank2ynab.json -------------------------------------------------------------------------------- /packages/ynap-bank2ynab-converter/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | src/ 4 | 5 | bank2ynab.json 6 | tsconfig.json 7 | 8 | yarn-error.log 9 | yarn.lock 10 | -------------------------------------------------------------------------------- /packages/ynap-bank2ynab-converter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ynap-bank2ynab-converter", 3 | "version": "1.14.0", 4 | "license": "MIT", 5 | "author": { 6 | "email": "admin+github@leolabs.org", 7 | "name": "Leo Bernard", 8 | "url": "https://leolabs.org" 9 | }, 10 | "bin": "index.js", 11 | "main": "index.js", 12 | "scripts": { 13 | "start": "ts-node src/index.ts", 14 | "prepublishOnly": "tsc && cp -r ./lib/* . && rm -rf ./lib" 15 | }, 16 | "devDependencies": { 17 | "@types/node": "18.16.16", 18 | "@types/node-fetch": "2.6.4", 19 | "ts-node": "10.9.2", 20 | "typescript": "3.9.10" 21 | }, 22 | "dependencies": { 23 | "commander": "^3.0.0", 24 | "encoding": "^0.1.12", 25 | "node-fetch": "^2.3.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/ynap-bank2ynab-converter/src/index.ts: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | import fetch from 'node-fetch'; 4 | import fs from 'fs'; 5 | import commander from 'commander'; 6 | 7 | commander 8 | .description('Fetch and parse the current bank2ynab config file to JSON') 9 | .option( 10 | '-e, --exclude ', 11 | 'Exclude banks by their name (comma-separated)', 12 | (v: string) => v.split(',').map((i) => i.trim()), 13 | ) 14 | .option( 15 | '-b, --branch ', 16 | 'Set the branch that the config should be fetched from', 17 | 'master', 18 | ) 19 | .option('-o, --output ', 'Set the output file path', 'bank2ynab.json') 20 | .parse(process.argv); 21 | 22 | import { ParserConfig } from './parserconfig'; 23 | 24 | const CONFIG_URL = `https://raw.githubusercontent.com/bank2ynab/bank2ynab/${commander.branch}/bank2ynab.conf`; 25 | 26 | const CONFIG_LINK = `https://github.com/bank2ynab/bank2ynab/blob/${commander.branch}/bank2ynab.conf`; 27 | 28 | const SECTION = new RegExp(/^\s*\[([^\]]+)]/); 29 | const KEY = new RegExp(/\s*(.*?)\s*[=:]\s*(.*)/); 30 | const COMMENT = new RegExp(/^\s*[;#]/); 31 | 32 | const blacklist = commander.exclude || []; 33 | 34 | interface Sections { 35 | [k: string]: ConfigFields; 36 | } 37 | 38 | interface ConfigFields { 39 | Line: string; 40 | 'Source Filename Pattern'?: string; 41 | 'Source Filename Extension'?: string; 42 | 'Header Rows'?: string; 43 | 'Footer Rows'?: string; 44 | 'Input Columns'?: string; 45 | 'Date Format'?: string; 46 | 'Inflow or Outflow Indicator'?: string; 47 | 'Source CSV Delimiter'?: string; 48 | Plugin?: string; 49 | [k: string]: string; 50 | } 51 | 52 | export const parseConfig = (config: string) => { 53 | const lines = config.split('\n'); 54 | 55 | const sections: Sections = {}; 56 | 57 | let currentSection = null; 58 | 59 | for (let i = 0; i < lines.length; i++) { 60 | const line = lines[i]; 61 | 62 | if (line.match(COMMENT)) { 63 | continue; 64 | } 65 | 66 | const sectionMatch = line.match(SECTION); 67 | if (sectionMatch) { 68 | currentSection = sectionMatch[1]; 69 | sections[currentSection] = { Line: String(i + 1) }; 70 | continue; 71 | } 72 | 73 | const keyMatch = line.match(KEY); 74 | if (currentSection && keyMatch && keyMatch[1] && keyMatch[2]) { 75 | const key = keyMatch[1].trim(); 76 | const value = keyMatch[2].trim(); 77 | if (Object.keys(sections).includes(currentSection)) { 78 | sections[currentSection][key] = value; 79 | } 80 | } 81 | } 82 | 83 | return sections; 84 | }; 85 | 86 | const script = async () => { 87 | const resp = await fetch(CONFIG_URL); 88 | 89 | if (!resp.ok) { 90 | throw new Error(`Fetch failed: ${resp.status}\n\n${resp.body}`); 91 | } 92 | 93 | const configData = await resp.textConverted(); 94 | 95 | const config = parseConfig(configData); 96 | 97 | console.log('Excluding', blacklist.length, 'items from blacklist.'); 98 | 99 | const filteredConfig: ParserConfig[] = Object.keys(config) 100 | .map((c) => ({ ...config[c], Name: c })) 101 | .filter( 102 | (c) => 103 | c.Name !== 'DEFAULT' && 104 | !blacklist.includes(c.Name) && 105 | !c.Plugin && 106 | c['Source Filename Pattern'] !== 'unknown!' && 107 | c['Input Columns'], 108 | ) 109 | .map( 110 | (c) => 111 | ({ 112 | name: c.Name.split(' ').slice(1).join(' '), 113 | country: c.Name.split(' ')[0].toLowerCase(), 114 | filenamePattern: `${c['Source Filename Pattern']}\\.${( 115 | c['Source Filename Extension'] || '.csv' 116 | ).substr(1)}`, 117 | filenameExtension: (c['Source Filename Extension'] || 'csv') 118 | .toLowerCase() 119 | .replace('.', ''), 120 | inputColumns: c['Input Columns'].split(','), 121 | link: `${CONFIG_LINK}#L${c.Line}`, 122 | dateFormat: c['Date Format'], 123 | inflowOutflowFlag: c['Inflow or Outflow Indicator'] 124 | ?.split(',') 125 | .map((s) => s.trim()), 126 | headerRows: Number(c['Header Rows'] || '1'), 127 | footerRows: Number(c['Footer Rows'] || '0'), 128 | } as ParserConfig), 129 | ); 130 | 131 | console.log( 132 | 'Parsed', 133 | filteredConfig.length, 134 | 'bank configs. Filtered from', 135 | Object.keys(config).length, 136 | 'configs.', 137 | ); 138 | 139 | fs.writeFileSync(commander.output, JSON.stringify(filteredConfig, null, 2)); 140 | console.log('Saved configs to', commander.output); 141 | }; 142 | 143 | script(); 144 | -------------------------------------------------------------------------------- /packages/ynap-bank2ynab-converter/src/parserconfig.ts: -------------------------------------------------------------------------------- 1 | export interface ParserConfig { 2 | filenamePattern: string; 3 | filenameExtension: string; 4 | headerRows: number; 5 | footerRows: number; 6 | inputColumns: string[]; 7 | inflowOutflowFlag?: [string, string, string]; 8 | dateFormat?: string; 9 | name: string; 10 | link: string; 11 | country: string; 12 | } 13 | -------------------------------------------------------------------------------- /packages/ynap-bank2ynab-converter/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "lib": ["es2017"], 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "resolveJsonModule": true, 8 | "outDir": "lib", 9 | "rootDir": "src" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/ynap-bank2ynab-converter/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@cspotcode/source-map-support@^0.8.0": 6 | version "0.8.1" 7 | resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" 8 | integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== 9 | dependencies: 10 | "@jridgewell/trace-mapping" "0.3.9" 11 | 12 | "@jridgewell/resolve-uri@^3.0.3": 13 | version "3.1.1" 14 | resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz#c08679063f279615a3326583ba3a90d1d82cc721" 15 | integrity sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA== 16 | 17 | "@jridgewell/sourcemap-codec@^1.4.10": 18 | version "1.4.15" 19 | resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" 20 | integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== 21 | 22 | "@jridgewell/trace-mapping@0.3.9": 23 | version "0.3.9" 24 | resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" 25 | integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== 26 | dependencies: 27 | "@jridgewell/resolve-uri" "^3.0.3" 28 | "@jridgewell/sourcemap-codec" "^1.4.10" 29 | 30 | "@tsconfig/node10@^1.0.7": 31 | version "1.0.9" 32 | resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2" 33 | integrity sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA== 34 | 35 | "@tsconfig/node12@^1.0.7": 36 | version "1.0.11" 37 | resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" 38 | integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== 39 | 40 | "@tsconfig/node14@^1.0.0": 41 | version "1.0.3" 42 | resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" 43 | integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== 44 | 45 | "@tsconfig/node16@^1.0.2": 46 | version "1.0.3" 47 | resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" 48 | integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== 49 | 50 | "@types/node-fetch@2.6.4": 51 | version "2.6.4" 52 | resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.4.tgz#1bc3a26de814f6bf466b25aeb1473fa1afe6a660" 53 | integrity sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg== 54 | dependencies: 55 | "@types/node" "*" 56 | form-data "^3.0.0" 57 | 58 | "@types/node@*": 59 | version "11.13.4" 60 | resolved "https://registry.yarnpkg.com/@types/node/-/node-11.13.4.tgz#f83ec3c3e05b174b7241fadeb6688267fe5b22ca" 61 | integrity sha512-+rabAZZ3Yn7tF/XPGHupKIL5EcAbrLxnTr/hgQICxbeuAfWtT0UZSfULE+ndusckBItcv4o6ZeOJplQikVcLvQ== 62 | 63 | "@types/node@18.16.16": 64 | version "18.16.16" 65 | resolved "https://registry.yarnpkg.com/@types/node/-/node-18.16.16.tgz#3b64862856c7874ccf7439e6bab872d245c86d8e" 66 | integrity sha512-NpaM49IGQQAUlBhHMF82QH80J08os4ZmyF9MkpCzWAGuOHqE4gTEbhzd7L3l5LmWuZ6E0OiC1FweQ4tsiW35+g== 67 | 68 | acorn-walk@^8.1.1: 69 | version "8.2.0" 70 | resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" 71 | integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== 72 | 73 | acorn@^8.4.1: 74 | version "8.8.2" 75 | resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" 76 | integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== 77 | 78 | arg@^4.1.0: 79 | version "4.1.0" 80 | resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.0.tgz#583c518199419e0037abb74062c37f8519e575f0" 81 | integrity sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg== 82 | 83 | asynckit@^0.4.0: 84 | version "0.4.0" 85 | resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" 86 | integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= 87 | 88 | combined-stream@^1.0.8: 89 | version "1.0.8" 90 | resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" 91 | integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== 92 | dependencies: 93 | delayed-stream "~1.0.0" 94 | 95 | commander@^3.0.0: 96 | version "3.0.2" 97 | resolved "https://registry.yarnpkg.com/commander/-/commander-3.0.2.tgz#6837c3fb677ad9933d1cfba42dd14d5117d6b39e" 98 | integrity sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow== 99 | 100 | create-require@^1.1.0: 101 | version "1.1.1" 102 | resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" 103 | integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== 104 | 105 | delayed-stream@~1.0.0: 106 | version "1.0.0" 107 | resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" 108 | integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= 109 | 110 | diff@^4.0.1: 111 | version "4.0.1" 112 | resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.1.tgz#0c667cb467ebbb5cea7f14f135cc2dba7780a8ff" 113 | integrity sha512-s2+XdvhPCOF01LRQBC8hf4vhbVmI2CGS5aZnxLJlT5FtdhPCDFq80q++zK2KlrVorVDdL5BOGZ/VfLrVtYNF+Q== 114 | 115 | encoding@^0.1.12: 116 | version "0.1.13" 117 | resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" 118 | integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== 119 | dependencies: 120 | iconv-lite "^0.6.2" 121 | 122 | form-data@^3.0.0: 123 | version "3.0.1" 124 | resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f" 125 | integrity sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg== 126 | dependencies: 127 | asynckit "^0.4.0" 128 | combined-stream "^1.0.8" 129 | mime-types "^2.1.12" 130 | 131 | iconv-lite@^0.6.2: 132 | version "0.6.3" 133 | resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" 134 | integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== 135 | dependencies: 136 | safer-buffer ">= 2.1.2 < 3.0.0" 137 | 138 | make-error@^1.1.1: 139 | version "1.3.5" 140 | resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.5.tgz#efe4e81f6db28cadd605c70f29c831b58ef776c8" 141 | integrity sha512-c3sIjNUow0+8swNwVpqoH4YCShKNFkMaw6oH1mNS2haDZQqkeZFlHS3dhoeEbKKmJB4vXpJucU6oH75aDYeE9g== 142 | 143 | mime-db@1.43.0: 144 | version "1.43.0" 145 | resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.43.0.tgz#0a12e0502650e473d735535050e7c8f4eb4fae58" 146 | integrity sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ== 147 | 148 | mime-types@^2.1.12: 149 | version "2.1.26" 150 | resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.26.tgz#9c921fc09b7e149a65dfdc0da4d20997200b0a06" 151 | integrity sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ== 152 | dependencies: 153 | mime-db "1.43.0" 154 | 155 | node-fetch@^2.3.0: 156 | version "2.6.11" 157 | resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.11.tgz#cde7fc71deef3131ef80a738919f999e6edfff25" 158 | integrity sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w== 159 | dependencies: 160 | whatwg-url "^5.0.0" 161 | 162 | "safer-buffer@>= 2.1.2 < 3.0.0": 163 | version "2.1.2" 164 | resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" 165 | integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== 166 | 167 | tr46@~0.0.3: 168 | version "0.0.3" 169 | resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" 170 | integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== 171 | 172 | ts-node@10.9.2: 173 | version "10.9.2" 174 | resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" 175 | integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== 176 | dependencies: 177 | "@cspotcode/source-map-support" "^0.8.0" 178 | "@tsconfig/node10" "^1.0.7" 179 | "@tsconfig/node12" "^1.0.7" 180 | "@tsconfig/node14" "^1.0.0" 181 | "@tsconfig/node16" "^1.0.2" 182 | acorn "^8.4.1" 183 | acorn-walk "^8.1.1" 184 | arg "^4.1.0" 185 | create-require "^1.1.0" 186 | diff "^4.0.1" 187 | make-error "^1.1.1" 188 | v8-compile-cache-lib "^3.0.1" 189 | yn "3.1.1" 190 | 191 | typescript@3.9.10: 192 | version "3.9.10" 193 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.10.tgz#70f3910ac7a51ed6bef79da7800690b19bf778b8" 194 | integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q== 195 | 196 | v8-compile-cache-lib@^3.0.1: 197 | version "3.0.1" 198 | resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" 199 | integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== 200 | 201 | webidl-conversions@^3.0.0: 202 | version "3.0.1" 203 | resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" 204 | integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== 205 | 206 | whatwg-url@^5.0.0: 207 | version "5.0.0" 208 | resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" 209 | integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== 210 | dependencies: 211 | tr46 "~0.0.3" 212 | webidl-conversions "^3.0.0" 213 | 214 | yn@3.1.1: 215 | version "3.1.1" 216 | resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" 217 | integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== 218 | -------------------------------------------------------------------------------- /packages/ynap-parsers/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | coverage/ 4 | 5 | index.spec.d.ts 6 | index.spec.js 7 | 8 | yarn-error.log 9 | 10 | /index.js 11 | /index.d.ts 12 | /util/ 13 | /??/ 14 | /bank2ynab/ 15 | /mt940/ 16 | /international/ -------------------------------------------------------------------------------- /packages/ynap-parsers/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | src/ 4 | 5 | index.spec.d.ts 6 | index.spec.js 7 | 8 | yarn-error.log -------------------------------------------------------------------------------- /packages/ynap-parsers/README.md: -------------------------------------------------------------------------------- 1 | # YNAP Parsers 2 | 3 | This package contains parsers that can convert banking statements from a variety of 4 | formats into a CSV format that can be imported into 5 | [You Need A Budget](https://youneedabudget.com). If you just want to use those 6 | parsers to convert your banking statements, you can do so using our web app, 7 | [You Need A Parser](https://ynap.leolabs.org). 8 | 9 | ## Supported Formats 10 | 11 | A list of all currently supported formats is available on the 12 | [Supported Formats](https://ynap.leolabs.org/supported-formats) page. 13 | 14 | ## Contributing 15 | 16 | If you want ynap-parsers to support a new format, you have two options: 17 | 18 | ### 1. [Request a Format](https://github.com/leolabs/you-need-a-parser/issues/new?template=format_request.md) 19 | 20 | This is the simplest way if you don't want to implement the parser yourself. 21 | Tell me which format you'd like to see supported and attach an example file if you 22 | have one. 23 | 24 | ### 2. Submit a Pull Request 25 | 26 | Adding a new format is fairly straight-forward. Take a look at one of the 27 | implemented parsers (e.g. [Kontist](https://github.com/leolabs/you-need-a-parser/blob/master/packages/ynap-parsers/src/de/kontist/kontist.ts)). Every parser file basically consists of 28 | two functions: A matcher that checks if a given file is supported and a parser 29 | that converts a given file into one or more arrays of YNAB-supported rows. 30 | 31 | Every parser module should be accompanied by a [test suite](https://github.com/leolabs/you-need-a-parser/blob/master/packages/ynap-parsers/src/de/kontist/kontist.spec.ts) to make sure that 32 | it operates correctly. 33 | -------------------------------------------------------------------------------- /packages/ynap-parsers/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | }; 9 | -------------------------------------------------------------------------------- /packages/ynap-parsers/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ynap-parsers", 3 | "version": "1.15.0", 4 | "description": "Parsers from various formats to YNAB CSV", 5 | "main": "index.js", 6 | "author": "Leo Bernard ", 7 | "license": "MIT", 8 | "private": false, 9 | "dependencies": { 10 | "date-fns": "2.30.0", 11 | "iban": "^0.0.14", 12 | "iconv-lite": "^0.6.0", 13 | "jschardet": "^2.1.0", 14 | "lodash": "^4.17.15", 15 | "mdn-polyfills": "^5.18.0", 16 | "mt940-js": "^0.6.0", 17 | "papaparse": "^5.1.0", 18 | "slugify": "^1.3.5", 19 | "xlsx": "^0.18.0" 20 | }, 21 | "devDependencies": { 22 | "@types/iban": "0.0.35", 23 | "@types/jest": "24.9.1", 24 | "@types/lodash": "4.14.191", 25 | "@types/papaparse": "5.0.3", 26 | "fast-glob": "3.2.12", 27 | "jest": "24.9.0", 28 | "ts-jest": "24.3.0", 29 | "typescript": "3.9.10", 30 | "ynap-bank2ynab-converter": "^1.14.0" 31 | }, 32 | "scripts": { 33 | "test": "jest", 34 | "test:watch": "jest --watch", 35 | "build": "yarn jest && tsc && cp -r ./lib/* . && rm -rf ./lib", 36 | "fetch-bank2ynab": "ynap-bank2ynab-converter -b develop -o src/bank2ynab/banks.json", 37 | "prepublishOnly": "yarn build" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/at/ing/ing-austria.spec.ts: -------------------------------------------------------------------------------- 1 | import { generateYnabDate, ingAustria } from './ing-austria'; 2 | import { YnabFile } from '../..'; 3 | import { encode } from 'iconv-lite'; 4 | 5 | const content = encode( 6 | `IBAN;Text;Valutadatum;Währung;Soll;Haben 7 | AT483200000012345864;Outflow from Max Mustermann;01.12.2019;EUR;100,01;0,00 8 | AT483200000012345864;Inflow from John Doe;02.12.2019;EUR;0,00;200,01`, 9 | 'ISO-8859-1', 10 | ); 11 | 12 | const ynabResult: YnabFile[] = [ 13 | { 14 | data: [ 15 | { 16 | Date: '12/01/2019', 17 | Payee: 'Outflow from Max Mustermann', 18 | Memo: undefined, 19 | Outflow: '100.01', 20 | Inflow: undefined, 21 | }, 22 | { 23 | Date: '12/02/2019', 24 | Payee: 'Inflow from John Doe', 25 | Memo: undefined, 26 | Outflow: undefined, 27 | Inflow: '200.01', 28 | }, 29 | ], 30 | }, 31 | ]; 32 | 33 | describe('ING Austria Parser Module', () => { 34 | describe('Matcher', () => { 35 | it('should match ING Austria files by file name', async () => { 36 | const fileName = 'ING_Umsaetze.csv'; 37 | const result = !!fileName.match(ingAustria.filenamePattern); 38 | expect(result).toBe(true); 39 | }); 40 | 41 | it('should not match other files by file name', async () => { 42 | const invalidFile = new File([], 'test.csv'); 43 | const result = await ingAustria.match(invalidFile); 44 | expect(result).toBe(false); 45 | }); 46 | 47 | it('should match ING Austria files by fields', async () => { 48 | const file = new File([content], 'test.csv'); 49 | const result = await ingAustria.match(file); 50 | expect(result).toBe(true); 51 | }); 52 | 53 | it('should not match empty files', async () => { 54 | const file = new File([], 'test.csv'); 55 | const result = await ingAustria.match(file); 56 | expect(result).toBe(false); 57 | }); 58 | }); 59 | 60 | describe('Parser', () => { 61 | it('should parse data correctly', async () => { 62 | const file = new File([content], 'test.csv'); 63 | const result = await ingAustria.parse(file); 64 | expect(result).toEqual(ynabResult); 65 | }); 66 | }); 67 | 68 | describe('Date Converter', () => { 69 | it('should format an input date correctly', () => { 70 | expect(generateYnabDate('03.05.2018')).toEqual('05/03/2018'); 71 | }); 72 | 73 | it('should throw an error when the input date is incorrect', () => { 74 | expect(() => generateYnabDate('1.1.1')).toThrow('not a valid date'); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/at/ing/ing-austria.ts: -------------------------------------------------------------------------------- 1 | import 'mdn-polyfills/String.prototype.startsWith'; 2 | import { ParserFunction, MatcherFunction, ParserModule } from '../..'; 3 | import { parse } from '../../util/papaparse'; 4 | import { readEncodedFile } from '../../util/read-encoded-file'; 5 | 6 | export interface IngAustriaRow { 7 | IBAN: string; 8 | Text: string; 9 | Valutadatum: string; 10 | Währung: string; 11 | Soll: string; 12 | Haben: string; 13 | } 14 | 15 | export const generateYnabDate = (input: string) => { 16 | const match = input.match(/(\d{2})\.(\d{2})\.(\d{4})/); 17 | 18 | if (!match) { 19 | throw new Error('The input is not a valid date. Expected format: YYYY.MM.DD'); 20 | } 21 | 22 | const [, day, month, year] = match; 23 | return [month.padStart(2, '0'), day.padStart(2, '0'), year].join('/'); 24 | }; 25 | 26 | export const parseNumber = (input: string) => Number(input.replace(',', '.')); 27 | 28 | export const ingAustriaParser: ParserFunction = async (file: File) => { 29 | const fileString = await readEncodedFile(file); 30 | const { data } = await parse(fileString, { header: true, delimiter: ';' }); 31 | 32 | return [ 33 | { 34 | data: (data as IngAustriaRow[]) 35 | .filter(r => r.Valutadatum && (r.Soll || r.Haben)) 36 | .map(r => ({ 37 | Date: generateYnabDate(r.Valutadatum), 38 | Payee: r.Text, 39 | Memo: undefined, 40 | Outflow: r.Soll != "0,00" ? parseNumber(r.Soll).toFixed(2) : undefined, 41 | Inflow: r.Haben != "0,00" ? parseNumber(r.Haben).toFixed(2) : undefined, 42 | })), 43 | }, 44 | ]; 45 | }; 46 | 47 | export const ingAustriaMatcher: MatcherFunction = async (file: File) => { 48 | const requiredKeys: (keyof IngAustriaRow)[] = [ 49 | 'IBAN', 50 | 'Text', 51 | 'Valutadatum', 52 | 'Währung', 53 | 'Soll', 54 | 'Haben', 55 | ]; 56 | 57 | const rawFileString = await readEncodedFile(file); 58 | 59 | if (rawFileString.startsWith('IBAN;Text;Valutadatum;Währung;Soll;Haben')) { 60 | return true; 61 | } 62 | 63 | try { 64 | const { data } = await parse(rawFileString, { 65 | header: true, 66 | delimiter: ';', 67 | }); 68 | 69 | if (data.length === 0) { 70 | return false; 71 | } 72 | 73 | const keys = Object.keys(data[0]); 74 | const missingKeys = requiredKeys.filter(k => !keys.includes(k)); 75 | 76 | if (missingKeys.length === 0) { 77 | return true; 78 | } 79 | } catch (e) { 80 | return false; 81 | } 82 | 83 | return false; 84 | }; 85 | 86 | export const ingAustria: ParserModule = { 87 | name: 'ING Austria', 88 | country: 'at', 89 | fileExtension: 'csv', 90 | filenamePattern: /^ING_Umsaetze\.csv$/, 91 | link: 'https://www.ing.at/', 92 | match: ingAustriaMatcher, 93 | parse: ingAustriaParser, 94 | }; 95 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/at/ing/test-data/ING_Umsaetze.csv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-parsers/src/at/ing/test-data/ING_Umsaetze.csv -------------------------------------------------------------------------------- /packages/ynap-parsers/src/bank2ynab/bank2ynab.spec.ts: -------------------------------------------------------------------------------- 1 | import { calculateInflow, calculateOutflow, parseNumber } from './bank2ynab'; 2 | 3 | describe('bank2ynab Parser Module', () => { 4 | describe('Number Parser', () => { 5 | it('should parse numbers in different formats correctly', () => { 6 | expect(parseNumber('10,00')).toBe(10); 7 | expect(parseNumber('12.50 ')).toBe(12.5); 8 | }); 9 | 10 | it('should return NaN when a number is invalid', () => { 11 | expect(parseNumber('test')).toBeNaN(); 12 | }); 13 | }); 14 | 15 | describe('Inflow Parser', () => { 16 | it('should parse inflow correctly', () => { 17 | expect(calculateInflow(undefined, undefined)).toBeUndefined(); 18 | expect(calculateInflow(10, undefined)).toBe(10); 19 | expect(calculateInflow(0, undefined)).toBe(0); 20 | expect(calculateInflow(-10, undefined)).toBeUndefined(); 21 | expect(() => calculateInflow(10, 20)).toThrow(); 22 | expect(calculateInflow(undefined, -20)).toBe(20); 23 | }); 24 | }); 25 | 26 | describe('Outflow Parser', () => { 27 | it('should parse outflow correctly', () => { 28 | expect(calculateOutflow(undefined, undefined)).toBeUndefined(); 29 | expect(calculateOutflow(undefined, 10)).toBe(10); 30 | expect(calculateOutflow(undefined, 0)).toBe(0); 31 | expect(calculateOutflow(undefined, -10)).toBeUndefined(); 32 | expect(() => calculateOutflow(10, 20)).toThrow(); 33 | expect(calculateOutflow(-20, undefined)).toBe(20); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/bank2ynab/bank2ynab.ts: -------------------------------------------------------------------------------- 1 | import 'mdn-polyfills/String.prototype.startsWith'; 2 | import { ParserFunction, MatcherFunction, ParserModule, YnabRow } from '..'; 3 | import { parse as parseCsv } from '../util/papaparse'; 4 | import { readEncodedFile } from '../util/read-encoded-file'; 5 | import { parseDate, ynabDate } from './parse-date'; 6 | import { ParserConfig } from 'ynap-bank2ynab-converter/parserconfig'; 7 | 8 | import banks from './banks.json'; 9 | 10 | export const parseNumber = (input?: string) => { 11 | if (typeof input === 'undefined') { 12 | return undefined; 13 | } 14 | 15 | try { 16 | if (input.includes(',')) { 17 | return Number(input.replace(',', '.')); 18 | } 19 | 20 | return Number(input); 21 | } catch (e) { 22 | return undefined; 23 | } 24 | }; 25 | 26 | export const calculateInflow = (inflow?: number, outflow?: number) => { 27 | if (typeof inflow === 'undefined' && typeof outflow === 'undefined') { 28 | return undefined; 29 | } 30 | 31 | if (typeof inflow !== 'undefined' && typeof outflow === 'undefined') { 32 | return inflow < 0 ? undefined : inflow; 33 | } 34 | 35 | if (typeof inflow === 'undefined' && typeof outflow !== 'undefined') { 36 | return outflow < 0 ? -outflow : undefined; 37 | } 38 | 39 | if (typeof inflow !== 'undefined' && typeof outflow !== 'undefined') { 40 | throw new Error("Inflow and outflow can't be set simultaneously"); 41 | } 42 | }; 43 | 44 | export const calculateOutflow = (inflow?: number, outflow?: number) => { 45 | if (typeof outflow === 'undefined' && typeof inflow === 'undefined') { 46 | return undefined; 47 | } 48 | 49 | if (typeof outflow !== 'undefined' && typeof inflow === 'undefined') { 50 | return outflow < 0 ? undefined : outflow; 51 | } 52 | 53 | if (typeof outflow === 'undefined' && typeof inflow !== 'undefined') { 54 | return inflow < 0 ? -inflow : undefined; 55 | } 56 | 57 | if (typeof outflow !== 'undefined' && typeof inflow !== 'undefined') { 58 | throw new Error("Inflow and outflow can't be set simultaneously"); 59 | } 60 | }; 61 | 62 | export const generateParser = (config: ParserConfig) => { 63 | const columns = config.inputColumns.reduce((acc, cur, index) => { 64 | if (cur === 'skip') { 65 | return acc; 66 | } 67 | 68 | return { 69 | ...acc, 70 | [cur]: index, 71 | }; 72 | }, {} as { [k in keyof (YnabRow & { CDFlag?: string })]: number }); 73 | 74 | const hasCol = (name: keyof typeof columns) => Object.keys(columns).includes(name); 75 | 76 | const match: MatcherFunction = async (file: File) => { 77 | const content = await readEncodedFile(file); 78 | const { data } = await parseCsv(content.trim()); 79 | 80 | const match = file.name.match(new RegExp(config.filenamePattern)); 81 | 82 | if (!match) { 83 | return false; 84 | } 85 | 86 | // Check that enough columns exist 87 | if (data.length === 0 || data[0].length < config.inputColumns.length) { 88 | return false; 89 | } 90 | 91 | const row = data.filter((d) => d.length > 1)[config.headerRows]; 92 | 93 | // Check that the date column is set correctly 94 | try { 95 | if (!parseDate(row[columns.Date], config.dateFormat)) { 96 | return false; 97 | } 98 | } catch (e) { 99 | return false; 100 | } 101 | 102 | // Check that the payee column is not a date 103 | try { 104 | if (columns.Payee && parseDate(row[columns.Payee], config.dateFormat)) { 105 | return false; 106 | } 107 | } catch (e) {} 108 | 109 | // Check that the inflow column is set correctly, if it exists 110 | if (columns.Inflow && isNaN(parseNumber(row[columns.Inflow]))) { 111 | return false; 112 | } 113 | 114 | // Check that the outflow column is set correctly, if it exists 115 | if (columns.Outflow && isNaN(parseNumber(row[columns.Outflow]))) { 116 | return false; 117 | } 118 | 119 | return true; 120 | }; 121 | 122 | const parse: ParserFunction = async (file: File) => { 123 | const content = await readEncodedFile(file); 124 | const { data } = await parseCsv(content.trim()); 125 | 126 | const ynabData = data 127 | .slice(config.headerRows, data.length - config.footerRows) 128 | .filter((d) => d.length > 1) 129 | .map( 130 | (d) => 131 | ({ 132 | Category: hasCol('Category') ? d[columns.Category] : undefined, 133 | Payee: hasCol('Payee') ? d[columns.Payee] : undefined, 134 | Date: hasCol('Date') 135 | ? ynabDate(parseDate(d[columns.Date], config.dateFormat)) 136 | : undefined, 137 | ...(config.inflowOutflowFlag && hasCol('CDFlag') 138 | ? { 139 | Inflow: 140 | d[columns.CDFlag].trim() === config.inflowOutflowFlag[1] 141 | ? parseNumber(d[columns.Inflow]) 142 | : undefined, 143 | Outflow: 144 | d[columns.CDFlag].trim() === config.inflowOutflowFlag[2] 145 | ? parseNumber(d[columns.Inflow]) 146 | : undefined, 147 | } 148 | : { 149 | Inflow: calculateInflow( 150 | hasCol('Inflow') ? parseNumber(d[columns.Inflow]) : undefined, 151 | hasCol('Outflow') ? parseNumber(d[columns.Outflow]) : undefined, 152 | ), 153 | Outflow: calculateOutflow( 154 | hasCol('Inflow') ? parseNumber(d[columns.Inflow]) : undefined, 155 | hasCol('Outflow') ? parseNumber(d[columns.Outflow]) : undefined, 156 | ), 157 | }), 158 | } as YnabRow), 159 | ); 160 | 161 | return [ 162 | { 163 | data: ynabData, 164 | }, 165 | ]; 166 | }; 167 | 168 | return { 169 | name: config.name, 170 | link: config.link, 171 | country: config.country, 172 | filenamePattern: new RegExp(config.filenamePattern), 173 | fileExtension: config.filenameExtension || 'csv', 174 | match, 175 | parse, 176 | } as ParserModule; 177 | }; 178 | 179 | const blacklist = ['de N26', 'de ING-DiBa', 'ie N26']; 180 | export const bank2ynab = banks 181 | .filter((b) => !blacklist.includes(`${b.country} ${b.name}`)) 182 | .map((bank) => generateParser(bank as ParserConfig)); 183 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/bank2ynab/parse-date.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseDate, ynabDate } from './parse-date'; 2 | import { format } from 'date-fns'; 3 | 4 | describe('bank2ynab Date Parser', () => { 5 | it('should parse dates correctly', () => { 6 | const result1 = parseDate('20.12.2018', '%d.%m.%Y'); 7 | expect(format(result1, 'MM/dd/yyyy')).toBe('12/20/2018'); 8 | 9 | const result2 = parseDate('20/12/2018', '%d/%m/%Y'); 10 | expect(format(result2, 'MM/dd/yyyy')).toBe('12/20/2018'); 11 | 12 | const result3 = parseDate('10 Feb 18', '%d %b %y'); 13 | expect(format(result3, 'MM/dd/yyyy')).toBe('02/10/2018'); 14 | 15 | const result4 = parseDate('10 Feb 18', '%d %b %y'); 16 | expect(format(result4, 'MM/dd/yyyy')).toBe('02/10/2018'); 17 | 18 | const result5 = parseDate('2019-03-04'); 19 | expect(format(result5, 'MM/dd/yyyy')).toBe('03/04/2019'); 20 | }); 21 | }); 22 | 23 | describe('bank2ynab YNAB Date Formatter', () => { 24 | it('should format dates correctly according to the YNAB format', () => { 25 | const result1 = ynabDate(new Date('2018-03-18')); 26 | expect(result1).toBe('03/18/2018'); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/bank2ynab/parse-date.ts: -------------------------------------------------------------------------------- 1 | import { parse, format } from 'date-fns'; 2 | 3 | // See https://github.com/bank2ynab/bank2ynab/wiki/DateFormatting#dates-in-data-rows 4 | export const placeholders: { [k: string]: string } = { 5 | '%y': 'yy', // 2-digit year 6 | '%Y': 'yyyy', // 4-digit year 7 | '%m': 'MM', // 2-digit month 8 | '%b': 'MMM', // Month as abbreviated name 9 | '%d': 'dd', // 2-digit day 10 | '%H': 'HH', // 2-digit hour (24h) 11 | '%M': 'mm', // 2-digit minutes 12 | '%S': 'ss', // 2-digit seconds 13 | }; 14 | 15 | const ensureValidity = (date: Date, input: string) => { 16 | if (isNaN(date.getTime())) { 17 | throw new Error(`${input} is not a valid date.`); 18 | } 19 | 20 | return date; 21 | }; 22 | 23 | export const parseDate = (input: string, format?: string) => { 24 | if (!format) { 25 | return ensureValidity(new Date(Date.parse(input)), input); 26 | } 27 | 28 | const convertedFormat = Object.keys(placeholders).reduce( 29 | (acc, cur) => acc.replace(cur, placeholders[cur]), 30 | format, 31 | ); 32 | 33 | return ensureValidity(parse(input, convertedFormat, new Date()), input); 34 | }; 35 | 36 | export const ynabDate = (input: number | Date) => format(input, 'MM/dd/yyyy'); 37 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/bank2ynab/test-data/2018-04-14_10-52-13_bunq-transactieoverzicht.csv: -------------------------------------------------------------------------------- 1 | "Datum","Bedrag","Rekening","Tegenrekening","Naam","Omschrijving" 2 | "2018-03-01","-2,56","NL47BUNQ2025181418","NL89BUNQ2025105584","bunq","invoice 410408" 3 | "2018-03-09","750,00","NL47BUNQ2025181418","NL77RABO0311467415","W. KOELEWIJN EO","Naar Bunq" 4 | "2018-03-09","-18,90","NL47BUNQ2025181418","","Quality Nuts","Quality Nuts (EEMDIJK, NL)" 5 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/bank2ynab/test-data/20180226_12345678.csv: -------------------------------------------------------------------------------- 1 | Date,Description,Amount,Balance 2 | 10/02/2018,"MERCHANT NAME@12:34",-5.00,995.00 3 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/bank2ynab/test-data/2019-03-02_11-50-46_bunq-statement.csv: -------------------------------------------------------------------------------- 1 | "Date","Amount","Account","Counterparty","Name","Description" 2 | "2018-12-06","-8,78","NL26BUNQ2025126409","","CLOUDFLARE","CLOUDFLARE 650-3198939, US 9.95 USD, 1 USD = 0.88241 EUR" 3 | "2018-12-07","-7,08","NL26BUNQ2025126409","","CLOUDFLARE","CLOUDFLARE 650-3198939, US 8.03 USD, 1 USD = 0.88169 EUR" 4 | "2018-12-06","8,78","NL26BUNQ2025126409","","CLOUDFLARE","Refund: CLOUDFLARE (650-3198939, US) 1 USD = 1.133257403189066 EUR" 5 | "2018-12-06","-8,76","NL26BUNQ2025126409","","CLOUDFLARE","CLOUDFLARE 650-3198939, US 9.95 USD, 1 USD = 0.88040 EUR" 6 | "2018-12-07","7,08","NL26BUNQ2025126409","","CLOUDFLARE","Refund: CLOUDFLARE (650-3198939, US) 1 USD = 1.134180790960451 EUR" 7 | "2018-12-07","-7,07","NL26BUNQ2025126409","","CLOUDFLARE","CLOUDFLARE 650-3198939, US 8.03 USD, 1 USD = 0.88045 EUR" 8 | "2018-12-17","-7,99","NL26BUNQ2025126409","","NETFLIX.COM","NETFLIX.COM 14087249160, NL" 9 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/bank2ynab/test-data/CSV_A_20180414_112204.csv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-parsers/src/bank2ynab/test-data/CSV_A_20180414_112204.csv -------------------------------------------------------------------------------- /packages/ynap-parsers/src/bank2ynab/test-data/MonzoDataExport_February2018_2018-02-26_174335.csv: -------------------------------------------------------------------------------- 1 | id,created,amount,currency,local_amount,local_currency,category,emoji,description,address,notes,receipt 2 | tx_00009TziTeST1NG1ZWIPGS,2018-02-25 12:34:56 +0000,-10,GBP,-10,GBP,general,,Tesco,,, 3 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/bank2ynab/test-data/Movements_1234512345_201805271848.csv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-parsers/src/bank2ynab/test-data/Movements_1234512345_201805271848.csv -------------------------------------------------------------------------------- /packages/ynap-parsers/src/bank2ynab/test-data/TH_20180101-20180523_page_1.csv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-parsers/src/bank2ynab/test-data/TH_20180101-20180523_page_1.csv -------------------------------------------------------------------------------- /packages/ynap-parsers/src/bank2ynab/test-data/TH_20180101-20180523_strana_1.csv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-parsers/src/bank2ynab/test-data/TH_20180101-20180523_strana_1.csv -------------------------------------------------------------------------------- /packages/ynap-parsers/src/bank2ynab/test-data/TH_20180521-20180523.csv: -------------------------------------------------------------------------------- 1 | "Item";"Posting date";"Amount CZK";"Bank account details";"Execution date";"Variable symbol 1";"Cancellation";"Counteraccount name";"Constant symbol";"Specific symbol";"Message for payee";"Message for me";"Transaction reference number";"Client note";"Payment reference";"Reason for non-execution" 2 | "XXXXXX XXXXXX 1111";"1111/11/11";"-111,11";"XXXXX XXXXX XXXX XXXXX 11";"1111/11/11";"11111111";;"XXXXXXXXXXXX1111";"1111";"1111111111";;;"1111111111111";;; 3 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/bank2ynab/test-data/dba33fceecd62c3c727893361e0ba4d3.P000000027355791.csv: -------------------------------------------------------------------------------- 1 | 27 Feb 2018,UMC-, 7.80, ,MCDONALD'S (TAM KIOSK) SI NG 22FEB,,, 2 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/bank2ynab/test-data/export_BE11123456789012_20180304_1422.csv: -------------------------------------------------------------------------------- 1 | Rekeningnummer;Rubrieknaam;Naam;Munt;Afschriftnummer;Datum;Omschrijving;Valuta;Bedrag;Saldo;credit;debet;rekeningnummer tegenpartij;BIC tegenpartij;Naam tegenpartij;Adres tegenpartij;gestructureerde mededeling;Vrije mededeling 2 | BE11123456789012; ;SMITH JOHN;EUR; 02018038;28/02/2018;BIJDRAGE 01-02-2018 - 28-02-2018 28-02 KBC-PLUSREKENING;28/02/2018;-3,40;219,95; ;-3,40; ; ; ; ; ; 3 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/1822direkt/1822direkt.spec.ts: -------------------------------------------------------------------------------- 1 | import { generateYnabDate, _1822direkt } from './1822direkt'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import { YnabFile } from '../..'; 5 | 6 | const content = fs.readFileSync( 7 | path.join(__dirname, 'test-data', 'umsaetze-12345678-25.03.2020_11_45.csv'), 8 | ); 9 | 10 | const ynabResult: YnabFile[] = [ 11 | { 12 | accountName: '12345678', 13 | data: [ 14 | { 15 | Date: '03/25/2020', 16 | Payee: 'PayPal (Europe) S.a.r.l. et Cie., S.C.A.', 17 | Memo: 'PP.1494.PP . DOMINOSPIZZ, Ihr Einkauf bei DOMINOSPIZZ', 18 | Outflow: '19.99', 19 | Inflow: undefined, 20 | }, 21 | { 22 | Date: '03/23/2020', 23 | Payee: 'DIRK ROSSMANN GMBH//ORTSNAME/DE', 24 | Memo: 'SVWZ+2020-03-20T09:10 Debitk.4 2022-12', 25 | Outflow: '2.37', 26 | Inflow: undefined, 27 | }, 28 | { 29 | Date: '03/23/2020', 30 | Payee: 'REWE SAGT DANKE. 43400092//Ortsname/DE', 31 | Memo: 'SVWZ+2020-03-20T08:50 Debitk.4 2022-12', 32 | Outflow: '19.95', 33 | Inflow: undefined, 34 | }, 35 | ], 36 | }, 37 | ]; 38 | 39 | describe('1822direkt Parser Module', () => { 40 | describe('Matcher', () => { 41 | it('should match 1822direkt files by file name', async () => { 42 | const fileName = 'umsaetze-12345678-25.03.2020_11_45.csv'; 43 | const result = !!fileName.match(_1822direkt.filenamePattern); 44 | expect(result).toBe(true); 45 | }); 46 | 47 | it('should not match other files by file name', async () => { 48 | const invalidFile = new File([], 'test.csv'); 49 | const result = await _1822direkt.match(invalidFile); 50 | expect(result).toBe(false); 51 | }); 52 | 53 | it('should match 1822direkt files by fields', async () => { 54 | const file = new File([content], 'test.csv'); 55 | const result = await _1822direkt.match(file); 56 | expect(result).toBe(true); 57 | }); 58 | }); 59 | 60 | describe('Parser', () => { 61 | it('should parse data correctly', async () => { 62 | const file = new File([content], 'test.csv'); 63 | const result = await _1822direkt.parse(file); 64 | expect(result).toEqual(ynabResult); 65 | }); 66 | }); 67 | 68 | describe('Date Converter', () => { 69 | it('should format an input date correctly', () => { 70 | expect(generateYnabDate('03.05.2018')).toEqual('05/03/2018'); 71 | }); 72 | 73 | it('should throw an error when the input date is incorrect', () => { 74 | expect(() => generateYnabDate('1.1.1')).toThrow('not a valid date'); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/1822direkt/1822direkt.ts: -------------------------------------------------------------------------------- 1 | import 'mdn-polyfills/String.prototype.startsWith'; 2 | import { ParserFunction, MatcherFunction, ParserModule } from '../..'; 3 | import { parse } from '../../util/papaparse'; 4 | import { readEncodedFile } from '../../util/read-encoded-file'; 5 | 6 | export interface Row { 7 | Kontonummer: string; 8 | 'Datum/Zeit': string; 9 | Buchungstag: string; 10 | Wertstellung: string; 11 | 'Soll/Haben': string; 12 | Buchungsschlüssel: string; 13 | Buchungsart: string; 14 | 'Empfänger/Auftraggeber Name': string; 15 | 'Empfänger/Auftraggeber IBAN': string; 16 | 'Empfänger/Auftraggeber BIC': string; 17 | 'Glaeubiger-ID': string; 18 | Mandatsreferenz: string; 19 | Mandatsdatum: string; 20 | 'Vwz.0': string; 21 | 'Vwz.1': string; 22 | 'Vwz.2': string; 23 | 'Vwz.3': string; 24 | 'Vwz.4': string; 25 | 'Vwz.5': string; 26 | 'Vwz.6': string; 27 | 'Vwz.7': string; 28 | 'Vwz.8': string; 29 | 'Vwz.9': string; 30 | 'Vwz.10': string; 31 | 'Vwz.11': string; 32 | 'Vwz.12': string; 33 | 'Vwz.13': string; 34 | 'Vwz.14': string; 35 | 'Vwz.15': string; 36 | 'Vwz.16': string; 37 | 'Vwz.17': string; 38 | 'End-to-End-Identifikation': string; 39 | } 40 | 41 | export const generateYnabDate = (input: string) => { 42 | const match = input.match(/(\d{2})\.(\d{2})\.(\d{4})/); 43 | 44 | if (!match) { 45 | throw new Error('The input is not a valid date. Expected format: YYYY-MM-DD'); 46 | } 47 | 48 | const [, day, month, year] = match; 49 | return [month.padStart(2, '0'), day.padStart(2, '0'), year].join('/'); 50 | }; 51 | 52 | export const parseNumber = (input: string) => 53 | Number(input.replace(/\./g, '').replace(',', '.')); 54 | 55 | export const getMergedMemo = (r: Row) => 56 | [ 57 | r['Vwz.0'], 58 | r['Vwz.1'], 59 | r['Vwz.2'], 60 | r['Vwz.3'], 61 | r['Vwz.4'], 62 | r['Vwz.5'], 63 | r['Vwz.6'], 64 | r['Vwz.7'], 65 | r['Vwz.8'], 66 | r['Vwz.9'], 67 | r['Vwz.10'], 68 | r['Vwz.11'], 69 | r['Vwz.12'], 70 | r['Vwz.13'], 71 | r['Vwz.14'], 72 | r['Vwz.15'], 73 | r['Vwz.16'], 74 | r['Vwz.17'], 75 | ] 76 | .filter(Boolean) 77 | // When the string is 35 characters long, it's likely to overflow into the next 78 | // field, so we don't add a space. Otherwise, we add a space for separation. 79 | .map(s => (s.length >= 35 ? s : s + ' ')) 80 | .join('') 81 | .trim(); 82 | 83 | export const _1822direktParser: ParserFunction = async (file: File) => { 84 | const fileString = await readEncodedFile(file, 'win1252'); 85 | const { data }: { data: Row[] } = await parse(fileString, { header: true }); 86 | 87 | return [ 88 | { 89 | accountName: String(data[0]?.Kontonummer), 90 | data: (data as Row[]) 91 | .filter(r => r.Buchungstag && r['Soll/Haben']) 92 | .map(r => ({ 93 | Date: generateYnabDate(r.Buchungstag), 94 | Payee: r['Empfänger/Auftraggeber Name'], 95 | Memo: getMergedMemo(r), 96 | Outflow: 97 | parseNumber(r['Soll/Haben']) < 0 98 | ? (-parseNumber(r['Soll/Haben'])).toFixed(2) 99 | : undefined, 100 | Inflow: 101 | parseNumber(r['Soll/Haben']) > 0 102 | ? parseNumber(r['Soll/Haben']).toFixed(2) 103 | : undefined, 104 | })), 105 | }, 106 | ]; 107 | }; 108 | 109 | export const _1822direktMatcher: MatcherFunction = async (file: File) => { 110 | const requiredKeys: (keyof Row)[] = [ 111 | 'Buchungstag', 112 | 'Soll/Haben', 113 | 'Vwz.0', 114 | 'Empfänger/Auftraggeber Name', 115 | ]; 116 | 117 | const rawFileString = await readEncodedFile(file, 'win1252'); 118 | 119 | if ( 120 | rawFileString.startsWith( 121 | 'Kontonummer;Datum/Zeit;Buchungstag;Wertstellung;Soll/Haben;Buchungsschlüssel;Buchungsart;Empfänger/Auftraggeber Name;Empfänger/Auftraggeber IBAN;Empfänger/Auftraggeber BIC;Glaeubiger-ID;', 122 | ) 123 | ) { 124 | return true; 125 | } 126 | 127 | if (rawFileString.length === 0) { 128 | return false; 129 | } 130 | 131 | try { 132 | const { data } = await parse(rawFileString, { header: true }); 133 | 134 | if (data.length === 0) { 135 | return false; 136 | } 137 | 138 | const keys = Object.keys(data[0]); 139 | const missingKeys = requiredKeys.filter(k => !keys.includes(k)); 140 | 141 | if (missingKeys.length === 0) { 142 | return true; 143 | } 144 | } catch (e) { 145 | return false; 146 | } 147 | 148 | return false; 149 | }; 150 | 151 | export const _1822direkt: ParserModule = { 152 | name: '1822direkt', 153 | country: 'de', 154 | fileExtension: 'csv', 155 | filenamePattern: /^umsaetze-\d+-\d{2}\.\d{2}.\d{4}_\d{2}_\d{2}\.csv$/, 156 | link: 'https://www.1822direkt.de/', 157 | match: _1822direktMatcher, 158 | parse: _1822direktParser, 159 | }; 160 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/1822direkt/test-data/umsaetze-12345678-25.03.2020_11_45.csv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-parsers/src/de/1822direkt/test-data/umsaetze-12345678-25.03.2020_11_45.csv -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/comdirect/comdirect.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | generateYnabDate, 3 | comdirect, 4 | extractField, 5 | trimMetaData, 6 | } from './comdirect'; 7 | import { YnabFile } from '../..'; 8 | import { encode, decode } from 'iconv-lite'; 9 | 10 | const content = encode( 11 | `; 12 | "Umsätze Verrechnungskonto ";"Zeitraum: 30 Tage"; 13 | "Neuer Kontostand";"16,94 EUR"; 14 | 15 | "Buchungstag";"Wertstellung (Valuta)";"Vorgang";"Buchungstext";"Umsatz in EUR"; 16 | "offen";"--";"Kartenverfügung";"Kto/IBAN: 000000000000 Buchungstext: NETFLIX.COM Berlin DE 2021-01-01T00:00:00 ";"-15,99"; 17 | "01.04.2019";"03.04.2019";"Wertpapiere";" Buchungstext: ISHSII-MSCI EUR.SRI EOACC WPKNR: A1H7ZS ISIN: IE00B52VJ196 Ref. 25F1909221559359/2 ";"-119,98"; 18 | "01.04.2019";"01.04.2019";"DTA-glt. Buchung";" Zahlungspflichtiger: John DoeKto/IBAN: DE84100110012626835902 BLZ/BIC: NTSBDEB1XXX Buchungstext: Sparplan 1 Ref. H9219087I4644658/2 ";"180,00"; 19 | 20 | "Alter Kontostand";"16,89 EUR";`, 21 | 'win1252', 22 | ); 23 | 24 | const trimmedContent = `"Buchungstag";"Wertstellung (Valuta)";"Vorgang";"Buchungstext";"Umsatz in EUR"; 25 | "offen";"--";"Kartenverfügung";"Kto/IBAN: 000000000000 Buchungstext: NETFLIX.COM Berlin DE 2021-01-01T00:00:00 ";"-15,99"; 26 | "01.04.2019";"03.04.2019";"Wertpapiere";" Buchungstext: ISHSII-MSCI EUR.SRI EOACC WPKNR: A1H7ZS ISIN: IE00B52VJ196 Ref. 25F1909221559359/2 ";"-119,98"; 27 | "01.04.2019";"01.04.2019";"DTA-glt. Buchung";" Zahlungspflichtiger: John DoeKto/IBAN: DE84100110012626835902 BLZ/BIC: NTSBDEB1XXX Buchungstext: Sparplan 1 Ref. H9219087I4644658/2 ";"180,00";`; 28 | 29 | const ynabResult: YnabFile[] = [ 30 | { 31 | data: [ 32 | { 33 | Date: '04/01/2019', 34 | Memo: 'ISHSII-MSCI EUR.SRI EOACC WPKNR: A1H7ZS ISIN: IE00B52VJ196', 35 | Outflow: '119.98', 36 | }, 37 | { 38 | Date: '04/01/2019', 39 | Payee: 'John Doe', 40 | Memo: 'Sparplan 1', 41 | Inflow: '180.00', 42 | }, 43 | ], 44 | }, 45 | ]; 46 | 47 | describe('comdirect Parser Module', () => { 48 | describe('Matcher', () => { 49 | it('should match comdirect files by file name', async () => { 50 | const fileName = 'umsaetze_1182395441_20190403-2324.csv'; 51 | const result = !!fileName.match(comdirect.filenamePattern); 52 | expect(result).toBe(true); 53 | }); 54 | 55 | it('should not match other files by file name', async () => { 56 | const invalidFile = new File([], 'test.csv'); 57 | const result = await comdirect.match(invalidFile); 58 | expect(result).toBe(false); 59 | }); 60 | 61 | it('should match comdirect files by fields', async () => { 62 | const file = new File([content], 'test.csv'); 63 | const result = await comdirect.match(file); 64 | expect(result).toBe(true); 65 | }); 66 | 67 | it('should not match empty files', async () => { 68 | const file = new File([], 'test.csv'); 69 | const result = await comdirect.match(file); 70 | expect(result).toBe(false); 71 | }); 72 | }); 73 | 74 | describe('Parser', () => { 75 | it('should parse data correctly', async () => { 76 | const file = new File([content], 'test.csv'); 77 | const result = await comdirect.parse(file); 78 | expect(result).toEqual(ynabResult); 79 | }); 80 | }); 81 | 82 | describe('Date Converter', () => { 83 | it('should format an input date correctly', () => { 84 | expect(generateYnabDate('03.05.2018')).toEqual('05/03/2018'); 85 | }); 86 | 87 | it('should throw an error when the input date is incorrect', () => { 88 | expect(() => generateYnabDate('1.1.1')).toThrow('not a valid date'); 89 | }); 90 | }); 91 | 92 | describe('Field Extractor', () => { 93 | const postingText1 = 94 | ' Buchungstext: AMUNDI ETF MSCI WLD X EMU WPKNR: A0RPV6 ISIN: FR0010756114 Ref. 07F1909220100960/2 '; 95 | 96 | const postingText2 = 97 | ' Zahlungspflichtiger: John DoeKto/IBAN: DE27100777770209299700 BLZ/BIC: NTSBDEB1XXX Buchungstext: Sparplan 1 Ref. H9219087I4642658/2 '; 98 | 99 | it('should extract a given field from a posting text', () => { 100 | expect(extractField(postingText1, 'Buchungstext')).toEqual( 101 | 'AMUNDI ETF MSCI WLD X EMU WPKNR: A0RPV6 ISIN: FR0010756114', 102 | ); 103 | expect(extractField(postingText1, 'Ref')).toEqual('07F1909220100960/2'); 104 | expect(extractField(postingText2, 'Zahlungspflichtiger')).toEqual('John Doe'); 105 | expect(extractField(postingText2, 'Buchungstext')).toEqual('Sparplan 1'); 106 | expect(extractField(postingText2, 'Kto/IBAN')).toEqual( 107 | 'DE27100777770209299700', 108 | ); 109 | }); 110 | 111 | it("should return undefined when a field doesn't exist", () => { 112 | expect(extractField(postingText1, 'Zahlungspflichtiger')).toBeUndefined(); 113 | expect(extractField(postingText1, 'Auftraggeber')).toBeUndefined(); 114 | }); 115 | }); 116 | 117 | describe('Metadata Trimmer', () => { 118 | it('should trim all metadata from a valid input string', () => { 119 | expect(trimMetaData(decode(content, 'win1252'))).toEqual(trimmedContent); 120 | }); 121 | 122 | it('should throw an error when the input string is invalid', () => { 123 | expect(() => trimMetaData('invalid string')).toThrow( 124 | 'file format is incorrect', 125 | ); 126 | }); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/comdirect/comdirect.ts: -------------------------------------------------------------------------------- 1 | import 'mdn-polyfills/String.prototype.startsWith'; 2 | import { ParserFunction, MatcherFunction, ParserModule } from '../..'; 3 | import { parse } from '../../util/papaparse'; 4 | import { readEncodedFile } from '../../util/read-encoded-file'; 5 | 6 | export interface ComdirectRow { 7 | Buchungstag: string; 8 | 'Wertstellung (Valuta)': string; 9 | Vorgang: string; 10 | Buchungstext: string; 11 | 'Umsatz in EUR': string; 12 | } 13 | 14 | export const generateYnabDate = (input: string) => { 15 | const match = input.match(/(\d{2})\.(\d{2})\.(\d{4})/); 16 | 17 | if (!match) { 18 | throw new Error('The input is not a valid date. Expected format: YYYY-MM-DD'); 19 | } 20 | 21 | const [, day, month, year] = match; 22 | return [month.padStart(2, '0'), day.padStart(2, '0'), year].join('/'); 23 | }; 24 | 25 | export const parseNumber = (input: string) => Number(input.replace(',', '.')); 26 | 27 | export const trimMetaData = (input: string) => { 28 | const beginning = input.indexOf('"Buchungstag"'); 29 | const end = input.lastIndexOf('\n"Alter Kontostand"'); 30 | 31 | if (beginning === -1 || end === -1) { 32 | throw new Error( 33 | 'Metadata could not be trimmed because the file format is incorrect.', 34 | ); 35 | } 36 | 37 | return input 38 | .substr(beginning, input.length - beginning - (input.length - end)) 39 | .trim(); 40 | }; 41 | 42 | const postingTextFields = { 43 | Buchungstext: 'Buchungstext', 44 | Empfänger: 'Empfänger', 45 | Auftraggeber: 'Auftraggeber', 46 | Zahlungspflichtiger: 'Zahlungspflichtiger', 47 | 'Kto/IBAN': 'Kto/IBAN', 48 | 'BLZ/BIC': 'BLZ/BIC', 49 | Ref: 'Ref', 50 | }; 51 | 52 | export const extractField = ( 53 | postingText: string, 54 | field: keyof typeof postingTextFields, 55 | ) => { 56 | // First, split the input by field name 57 | // so we can remove everything before that. 58 | const split1 = postingText.split(field); 59 | 60 | if (split1.length < 2) { 61 | // Field doesn't exist 62 | return undefined; 63 | } 64 | 65 | // Next, split the new string again by any possible 66 | // key so we can remove everything after that. 67 | const nextField = new RegExp( 68 | `(${Object.keys(postingTextFields) 69 | .map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) 70 | .join('|')})`, 71 | 'i', 72 | ); 73 | const rawContent = split1[1].split(nextField)[0]; 74 | 75 | // Last, trim the content to remove any white spaces 76 | // or other residue from the previous operations. 77 | return rawContent.replace(/(^[:.\s]+|\s+$)/g, ''); 78 | }; 79 | 80 | export const comdirectParser: ParserFunction = async (file: File) => { 81 | const fileString = trimMetaData(await readEncodedFile(file)); 82 | const { data } = await parse(fileString, { header: true }); 83 | 84 | return [ 85 | { 86 | data: (data as ComdirectRow[]) 87 | .filter(r => r.Buchungstag && r.Buchungstag != "offen" && r['Umsatz in EUR']) 88 | .map(r => ({ 89 | Date: generateYnabDate(r.Buchungstag), 90 | Payee: 91 | extractField(r.Buchungstext, 'Empfänger') || 92 | extractField(r.Buchungstext, 'Zahlungspflichtiger') || 93 | extractField(r.Buchungstext, 'Auftraggeber'), 94 | Memo: extractField(r.Buchungstext, 'Buchungstext'), 95 | Outflow: 96 | parseNumber(r['Umsatz in EUR']) < 0 97 | ? (-parseNumber(r['Umsatz in EUR'])).toFixed(2) 98 | : undefined, 99 | Inflow: 100 | parseNumber(r['Umsatz in EUR']) > 0 101 | ? parseNumber(r['Umsatz in EUR']).toFixed(2) 102 | : undefined, 103 | })), 104 | }, 105 | ]; 106 | }; 107 | 108 | export const comdirectMatcher: MatcherFunction = async (file: File) => { 109 | const requiredKeys: (keyof ComdirectRow)[] = [ 110 | 'Buchungstag', 111 | 'Wertstellung (Valuta)', 112 | 'Buchungstext', 113 | 'Umsatz in EUR', 114 | 'Vorgang', 115 | ]; 116 | 117 | const rawFileString = await readEncodedFile(file); 118 | 119 | if (rawFileString.startsWith(';\n"Umsätze Verrechnungskonto')) { 120 | return true; 121 | } 122 | 123 | if (rawFileString.length === 0) { 124 | return false; 125 | } 126 | 127 | try { 128 | const { data } = await parse(trimMetaData(rawFileString), { header: true }); 129 | 130 | if (data.length === 0) { 131 | return false; 132 | } 133 | 134 | const keys = Object.keys(data[0]); 135 | const missingKeys = requiredKeys.filter(k => !keys.includes(k)); 136 | 137 | if (missingKeys.length === 0) { 138 | return true; 139 | } 140 | } catch (e) { 141 | return false; 142 | } 143 | 144 | return false; 145 | }; 146 | 147 | export const comdirect: ParserModule = { 148 | name: 'comdirect', 149 | country: 'de', 150 | fileExtension: 'csv', 151 | filenamePattern: /^umsaetze_\d+_[\d-]+\.csv$/, 152 | link: 'https://www.comdirect.de', 153 | match: comdirectMatcher, 154 | parse: comdirectParser, 155 | }; 156 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/comdirect/test-data/umsaetze_1182395341_20190403-2324.csv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-parsers/src/de/comdirect/test-data/umsaetze_1182395341_20190403-2324.csv -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/ing-diba/ing-diba.spec.ts: -------------------------------------------------------------------------------- 1 | import { generateYnabDate, ingDiBa } from './ing-diba'; 2 | import { YnabRow, YnabFile } from '../..'; 3 | import { encode } from 'iconv-lite'; 4 | 5 | const content = encode( 6 | `Umsatzanzeige;Datei erstellt am: 03.04.2019 22:16 7 | ;Letztes Update: aktuell 8 | 9 | IBAN;DE11 XXXX XXXX XXXX XXXX 72 10 | Kontoname;Girokonto 11 | Bank;ING 12 | Kunde;John Doe 13 | Zeitraum;03.04.2018 - 03.04.2019 14 | 15 | Sortierung;Datum absteigend 16 | 17 | In der CSV-Datei finden Sie alle bereits gebuchten Umsätze. Die vorgemerkten Umsätze werden nicht aufgenommen, auch wenn sie in Ihrem Internetbanking angezeigt werden. 18 | 19 | Buchung;Valuta;Auftraggeber/Empfänger;Buchungstext;Verwendungszweck;Betrag;Währung 20 | 03.04.2019;03.04.2019;eprimo GmbH;Lastschrift;eprimo sagt danke;-71,00;EUR 21 | 03.04.2019;03.04.2019;Income;Gehalt/Rente;MAERZ 2019;700,00;EUR 22 | 03.04.2020;03.04.2020;Income;Gehalt/Rente;MAERZ 2020;1.700,00;EUR 23 | 03.04.2020;03.04.2020;Thousand Euros gone;Lastschrift;Thank you;-1.000,00;EUR`, 24 | 'win1252', 25 | ); 26 | 27 | const ynabResult: YnabFile[] = [ 28 | { 29 | data: [ 30 | { 31 | Date: '04/03/2019', 32 | Payee: 'eprimo GmbH', 33 | Memo: 'eprimo sagt danke', 34 | Outflow: '71.00', 35 | Inflow: undefined, 36 | }, 37 | { 38 | Date: '04/03/2019', 39 | Payee: 'Income', 40 | Memo: 'MAERZ 2019', 41 | Outflow: undefined, 42 | Inflow: '700.00', 43 | }, 44 | { 45 | Date: '04/03/2020', 46 | Payee: 'Income', 47 | Memo: 'MAERZ 2020', 48 | Outflow: undefined, 49 | Inflow: '1700.00', 50 | }, 51 | { 52 | Date: '04/03/2020', 53 | Payee: 'Thousand Euros gone', 54 | Memo: 'Thank you', 55 | Outflow: '1000.00', 56 | Inflow: undefined, 57 | }, 58 | ], 59 | }, 60 | ]; 61 | 62 | describe('ING-DiBa Parser Module', () => { 63 | describe('Matcher', () => { 64 | it('should match ING-DiBa files by file name', async () => { 65 | const fileName = 'Umsatzanzeige_DE27100777770209299700_20190403.csv'; 66 | const result = !!fileName.match(ingDiBa.filenamePattern); 67 | expect(result).toBe(true); 68 | }); 69 | 70 | it('should not match other files by file name', async () => { 71 | const invalidFile = new File([], 'test.csv'); 72 | const result = await ingDiBa.match(invalidFile); 73 | expect(result).toBe(false); 74 | }); 75 | 76 | it('should match ING-DiBa files by fields', async () => { 77 | const file = new File([content], 'test.csv'); 78 | const result = await ingDiBa.match(file); 79 | expect(result).toBe(true); 80 | }); 81 | 82 | it('should not match empty files', async () => { 83 | const file = new File([], 'test.csv'); 84 | const result = await ingDiBa.match(file); 85 | expect(result).toBe(false); 86 | }); 87 | }); 88 | 89 | describe('Parser', () => { 90 | it('should parse data correctly', async () => { 91 | const file = new File([content], 'test.csv'); 92 | const result = await ingDiBa.parse(file); 93 | expect(result).toEqual(ynabResult); 94 | }); 95 | }); 96 | 97 | describe('Date Converter', () => { 98 | it('should format an input date correctly', () => { 99 | expect(generateYnabDate('03.05.2018')).toEqual('05/03/2018'); 100 | }); 101 | 102 | it('should throw an error when the input date is incorrect', () => { 103 | expect(() => generateYnabDate('1.1.1')).toThrow('not a valid date'); 104 | }); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/ing-diba/ing-diba.ts: -------------------------------------------------------------------------------- 1 | import 'mdn-polyfills/String.prototype.startsWith'; 2 | import { ParserFunction, MatcherFunction, ParserModule } from '../..'; 3 | import { parse } from '../../util/papaparse'; 4 | import { readEncodedFile } from '../../util/read-encoded-file'; 5 | 6 | export interface IngDiBaRow { 7 | Buchung: string; 8 | Valuta: string; 9 | 'Auftraggeber/Empfänger': string; 10 | Buchungstext: string; 11 | Verwendungszweck: string; 12 | Betrag: string; 13 | Währung: string; 14 | } 15 | 16 | export const generateYnabDate = (input: string) => { 17 | const match = input.match(/(\d{2})\.(\d{2})\.(\d{4})/); 18 | 19 | if (!match) { 20 | throw new Error('The input is not a valid date. Expected format: YYYY-MM-DD'); 21 | } 22 | 23 | const [, day, month, year] = match; 24 | return [month.padStart(2, '0'), day.padStart(2, '0'), year].join('/'); 25 | }; 26 | 27 | export const parseNumber = (input: string) => Number(input.replace('.','').replace(',', '.')); 28 | 29 | export const trimMetaData = (input: string) => 30 | input.substr(input.indexOf('Buchung;')); 31 | 32 | export const ingDiBaParser: ParserFunction = async (file: File) => { 33 | const fileString = trimMetaData(await readEncodedFile(file)); 34 | const { data } = await parse(fileString, { header: true, delimiter: ';' }); 35 | 36 | return [ 37 | { 38 | data: (data as IngDiBaRow[]) 39 | .filter(r => r.Buchung && r.Betrag) 40 | .map(r => ({ 41 | Date: generateYnabDate(r.Buchung), 42 | Payee: r['Auftraggeber/Empfänger'], 43 | Memo: r.Verwendungszweck, 44 | Outflow: 45 | parseNumber(r.Betrag) < 0 46 | ? (-parseNumber(r.Betrag)).toFixed(2) 47 | : undefined, 48 | Inflow: 49 | parseNumber(r.Betrag) > 0 ? parseNumber(r.Betrag).toFixed(2) : undefined, 50 | })), 51 | }, 52 | ]; 53 | }; 54 | 55 | export const ingDiBaMatcher: MatcherFunction = async (file: File) => { 56 | const requiredKeys: (keyof IngDiBaRow)[] = [ 57 | 'Buchung', 58 | 'Valuta', 59 | 'Auftraggeber/Empfänger', 60 | 'Buchungstext', 61 | 'Verwendungszweck', 62 | ]; 63 | 64 | const rawFileString = await readEncodedFile(file); 65 | 66 | if (rawFileString.startsWith('Umsatzanzeige;Datei erstellt am:')) { 67 | return true; 68 | } 69 | 70 | try { 71 | const { data } = await parse(trimMetaData(rawFileString), { 72 | header: true, 73 | delimiter: ';', 74 | }); 75 | 76 | if (data.length === 0) { 77 | return false; 78 | } 79 | 80 | const keys = Object.keys(data[0]); 81 | const missingKeys = requiredKeys.filter(k => !keys.includes(k)); 82 | 83 | if (missingKeys.length === 0) { 84 | return true; 85 | } 86 | } catch (e) { 87 | return false; 88 | } 89 | 90 | return false; 91 | }; 92 | 93 | export const ingDiBa: ParserModule = { 94 | name: 'ING-DiBa', 95 | country: 'de', 96 | fileExtension: 'csv', 97 | filenamePattern: /^Umsatzanzeige_(.+)_(\d{8})\.csv$/, 98 | link: 'https://www.ing-diba.de', 99 | match: ingDiBaMatcher, 100 | parse: ingDiBaParser, 101 | }; 102 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/ing-diba/test-data/Umsatzanzeige_DE00000105170000890000_20190403.csv: -------------------------------------------------------------------------------- 1 | Umsatzanzeige;Datei erstellt am: 03.04.2019 22:08 2 | ;Letztes Update: aktuell 3 | 4 | IBAN;DE11 0000 0000 0000 0000 72 5 | Kontoname;Girokonto 6 | Bank;ING 7 | Kunde;Test Nutzer 8 | Zeitraum;03.03.2019 - 03.04.2019 9 | Saldo;126,08;EUR 10 | 11 | Sortierung;Datum absteigend 12 | 13 | In der CSV-Datei finden Sie alle bereits gebuchten Ums�tze. Die vorgemerkten Ums�tze werden nicht aufgenommen, auch wenn sie in Ihrem Internetbanking angezeigt werden. 14 | 15 | Buchung;Valuta;Auftraggeber/Empf�nger;Buchungstext;Verwendungszweck;Saldo;W�hrung;Betrag;W�hrung 16 | 03.04.2019;03.04.2019;eprimo GmbH;Lastschrift;eprimo sagt danke;126,08;EUR;-71,00;EUR 17 | 03.04.2019;03.04.2019;eprimo GmbH;Lastschrift;eprimo sagt danke;197,08;EUR;-70,00;EUR 18 | 26.03.2019;26.03.2019;Max Mustermann;�berweisung;Miete;267,08;EUR;-670,00;EUR 19 | 26.03.2019;26.03.2019;John Doe;Gutschrift;Miete;937,08;EUR;290,00;EUR 20 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/kontist/kontist.spec.ts: -------------------------------------------------------------------------------- 1 | import { generateYnabDate, kontist } from './kontist'; 2 | import { YnabRow, YnabFile } from '../..'; 3 | 4 | const content = `booking_date,valuta_date,amount,name,purpose,end_to_end_id,booking_status 5 | 2018-07-19,2018-07-19,-18000,John Doe,Invoice 2018006,E-e5a5084f898385ba8bc869c9627d8d2,processed 6 | 2018-07-19,2018-07-19,55800,Company,LB-5-2018.2018-07-15.CRIS.10001,NOTPROVIDED,processed`; 7 | 8 | const ynabResult: YnabFile[] = [ 9 | { 10 | data: [ 11 | { 12 | Date: '07/19/2018', 13 | Payee: 'John Doe', 14 | Memo: 'Invoice 2018006', 15 | Outflow: '180.00', 16 | Inflow: undefined, 17 | }, 18 | { 19 | Date: '07/19/2018', 20 | Payee: 'Company', 21 | Memo: 'LB-5-2018.2018-07-15.CRIS.10001', 22 | Outflow: undefined, 23 | Inflow: '558.00', 24 | }, 25 | ], 26 | }, 27 | ]; 28 | 29 | describe('Kontist Parser Module', () => { 30 | describe('Matcher', () => { 31 | it('should match Kontist files by fields', async () => { 32 | const file = new File([content], 'test.csv'); 33 | const result = await kontist.match(file); 34 | expect(result).toBe(true); 35 | }); 36 | 37 | it('should not match empty files', async () => { 38 | const file = new File([], 'test.csv'); 39 | const result = await kontist.match(file); 40 | expect(result).toBe(false); 41 | }); 42 | }); 43 | 44 | describe('Parser', () => { 45 | it('should parse data correctly', async () => { 46 | const file = new File([content], 'test.csv'); 47 | const result = await kontist.parse(file); 48 | expect(result).toEqual(ynabResult); 49 | }); 50 | }); 51 | 52 | describe('Date Converter', () => { 53 | it('should convert dates correctly', () => { 54 | expect(generateYnabDate('2018-09-01')).toEqual('09/01/2018'); 55 | }); 56 | 57 | it('should throw an error when the input date is incorrect', () => { 58 | expect(() => generateYnabDate('1.1.1')).toThrow('not a valid date'); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/kontist/kontist.ts: -------------------------------------------------------------------------------- 1 | import 'mdn-polyfills/String.prototype.startsWith'; 2 | import { ParserFunction, MatcherFunction, ParserModule } from '../..'; 3 | import { parse } from '../../util/papaparse'; 4 | 5 | export interface KontistRow { 6 | booking_date: string; 7 | valuta_date: string; 8 | amount: string; 9 | name: string; 10 | purpose: string; 11 | end_to_end_id: string; 12 | booking_status: string; 13 | } 14 | 15 | export const generateYnabDate = (input: string) => { 16 | const match = input.match(/(\d{4})-(\d{2})-(\d{2})/); 17 | 18 | if (!match) { 19 | throw new Error('The input is not a valid date. Expected format: YYYY-MM-DD'); 20 | } 21 | 22 | const [, year, month, day] = match; 23 | return [month.padStart(2, '0'), day.padStart(2, '0'), year].join('/'); 24 | }; 25 | 26 | export const kontistParser: ParserFunction = async (file: File) => { 27 | const { data } = await parse(file, { header: true }); 28 | 29 | return [ 30 | { 31 | data: (data as KontistRow[]) 32 | .filter(r => r.booking_date && r.amount) 33 | .map(r => ({ 34 | Date: generateYnabDate(r.booking_date), 35 | Payee: r.name, 36 | Memo: r.purpose, 37 | Outflow: 38 | Number(r.amount) < 0 ? (-Number(r.amount) / 100).toFixed(2) : undefined, 39 | Inflow: 40 | Number(r.amount) > 0 ? (Number(r.amount) / 100).toFixed(2) : undefined, 41 | })), 42 | }, 43 | ]; 44 | }; 45 | 46 | export const kontistMatcher: MatcherFunction = async (file: File) => { 47 | const requiredKeys: (keyof KontistRow)[] = [ 48 | 'booking_date', 49 | 'valuta_date', 50 | 'name', 51 | 'purpose', 52 | 'end_to_end_id', 53 | 'booking_status', 54 | ]; 55 | 56 | const { data } = await parse(file, { header: true }); 57 | 58 | if (data.length === 0) { 59 | return false; 60 | } 61 | 62 | const keys = Object.keys(data[0]); 63 | const missingKeys = requiredKeys.filter(k => !keys.includes(k)); 64 | 65 | if (missingKeys.length === 0) { 66 | return true; 67 | } 68 | 69 | return false; 70 | }; 71 | 72 | export const kontist: ParserModule = { 73 | name: 'Kontist', 74 | country: 'de', 75 | fileExtension: 'csv', 76 | filenamePattern: /^transactions\.csv$/, 77 | link: 'https://intercom.help/kontist/konto/konto-export-von-kontoauszugen', 78 | match: kontistMatcher, 79 | parse: kontistParser, 80 | }; 81 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/kontist/test-data/transactions.csv: -------------------------------------------------------------------------------- 1 | booking_date,valuta_date,amount,name,purpose,end_to_end_id,booking_status 2 | 2018-07-19,2018-07-19,-18000,Max Mustermann,Rechnung 2017006,NOTPROVIDED,processed 3 | 2018-07-19,2018-07-19,55800,Employer GmbH,Purpose,NOTPROVIDED,processed 4 | 2018-07-23,2018-07-23,9510,John Appleseed,Purpose,NOTPROVIDED,processed 5 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/n26/n26.spec.ts: -------------------------------------------------------------------------------- 1 | import { generateYnabDate, n26 } from './n26'; 2 | import { YnabFile } from '../..'; 3 | import { unparse } from 'papaparse'; 4 | 5 | const content2021 = unparse([ 6 | { 7 | Date: '2019-01-01', 8 | Payee: 'Test Payee', 9 | 'Account number': 'DE27100777770209299700', 10 | 'Transaction type': 'Outgoing Transfer', 11 | 'Payment reference': 'Netflix', 12 | Category: 'Subscriptions & Donations', 13 | 'Amount (EUR)': '-3.0', 14 | 'Amount (Foreign Currency)': '', 15 | 'Type Foreign Currency': '', 16 | 'Exchange Rate': '', 17 | }, 18 | { 19 | Date: '2019-01-02', 20 | Payee: 'Work Account', 21 | 'Account number': '', 22 | 'Transaction type': 'MasterCard Payment', 23 | 'Payment reference': '', 24 | Category: 'Income', 25 | 'Amount (EUR)': '600.0', 26 | 'Amount (Foreign Currency)': '600.0', 27 | 'Type Foreign Currency': 'EUR', 28 | 'Exchange Rate': '1.0', 29 | }, 30 | ]); 31 | 32 | const ynabResult2021: YnabFile[] = [ 33 | { 34 | data: [ 35 | { 36 | Date: '01/01/2019', 37 | Payee: 'Test Payee', 38 | Category: 'Subscriptions & Donations', 39 | Memo: 'Netflix', 40 | Outflow: '3.00', 41 | Inflow: undefined, 42 | }, 43 | { 44 | Date: '01/02/2019', 45 | Payee: 'Work Account', 46 | Category: 'Income', 47 | Memo: '', 48 | Outflow: undefined, 49 | Inflow: '600.00', 50 | }, 51 | ], 52 | }, 53 | ]; 54 | 55 | const content2024 = unparse([ 56 | { 57 | 'Booking Date': '2024-08-01', 58 | 'Value Date': '2024-08-01', 59 | 'Partner Name': 'theName', 60 | 'Partner Iban': 'DE49100000000000000000', 61 | Type: 'Debit Transfer', 62 | 'Payment Reference': 'Rent payment', 63 | 'Account Name': 'Main Account', 64 | 'Amount (EUR)': '-519.20', 65 | 'Original Amount': '', 66 | 'Original Currency': '', 67 | 'Exchange Rate': '', 68 | }, 69 | { 70 | 'Booking Date': '2024-08-04', 71 | 'Value Date': '2024-08-03', 72 | 'Partner Name': 'A company', 73 | 'Partner Iban': '', 74 | Type: 'Presentment', 75 | 'Payment Reference': '', 76 | 'Account Name': 'Main Account', 77 | 'Amount (EUR)': '-324.83', 78 | 'Original Amount': '350', 79 | 'Original Currency': 'USD', 80 | 'Exchange Rate': '0.9280857143', 81 | }, 82 | ]); 83 | 84 | const ynabResult2024: YnabFile[] = [ 85 | { 86 | data: [ 87 | { 88 | Date: '08/01/2024', 89 | Payee: 'theName', 90 | Category: undefined, 91 | Memo: 'Rent payment', 92 | Outflow: '519.20', 93 | Inflow: undefined, 94 | }, 95 | { 96 | Date: '08/04/2024', 97 | Payee: 'A company', 98 | Category: undefined, 99 | Memo: '', 100 | Outflow: '324.83', 101 | Inflow: undefined, 102 | }, 103 | ], 104 | }, 105 | ]; 106 | 107 | describe('N26 Parser Module', () => { 108 | describe('Matcher', () => { 109 | it('should match N26 files by file name (old format)', async () => { 110 | const fileName = 'n26-csv-transactions.csv'; 111 | const result = !!fileName.match(n26.filenamePattern); 112 | expect(result).toBe(true); 113 | }); 114 | 115 | it('should match N26 files by file name (2024 format)', async () => { 116 | const fileName = 'MainAccount_2024-08-01_2024-09-07.csv'; 117 | const result = !!fileName.match(n26.filenamePattern); 118 | expect(result).toBe(true); 119 | }); 120 | 121 | it('should not match other files by file name', async () => { 122 | const fileName = 'invalid.csv'; 123 | const result = !!fileName.match(n26.filenamePattern); 124 | expect(result).toBe(false); 125 | }); 126 | 127 | it('should match N26 files until 2021 by fields', async () => { 128 | const file = new File([content2021], 'test.csv'); 129 | const result = await n26.match(file); 130 | expect(result).toBe(true); 131 | }); 132 | 133 | it('should match N26 files for 2024 format by fields', async () => { 134 | const file = new File([content2024], 'test.csv'); 135 | const result = await n26.match(file); 136 | expect(result).toBe(true); 137 | }); 138 | 139 | it('should not match empty files', async () => { 140 | const file = new File([], 'test.csv'); 141 | const result = await n26.match(file); 142 | expect(result).toBe(false); 143 | }); 144 | }); 145 | 146 | describe('Parser', () => { 147 | it('should parse data until 2021 correctly', async () => { 148 | const file = new File([content2021], 'test.csv'); 149 | const result = await n26.parse(file); 150 | expect(result).toEqual(ynabResult2021); 151 | }); 152 | 153 | it('should parse data for 2024 format correctly', async () => { 154 | const file = new File([content2024], 'test.csv'); 155 | const result = await n26.parse(file); 156 | expect(result).toEqual(ynabResult2024); 157 | }); 158 | }); 159 | 160 | describe('Date Converter', () => { 161 | it('should throw an error when the input date is incorrect', () => { 162 | expect(() => generateYnabDate('1.1.1')).toThrow( 163 | 'The input is not a valid date. Expected formats: YYYY-MM-DD or DD.MM.YYYY' 164 | ); 165 | }); 166 | 167 | it('should convert dates in old format correctly', () => { 168 | expect(generateYnabDate('01.01.2019')).toEqual('01/01/2019'); 169 | }); 170 | 171 | it('should convert dates in 2024 format correctly', () => { 172 | expect(generateYnabDate('2024-08-01')).toEqual('08/01/2024'); 173 | }); 174 | }); 175 | }); -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/n26/n26.ts: -------------------------------------------------------------------------------- 1 | import 'mdn-polyfills/String.prototype.startsWith'; 2 | import { ParserFunction, MatcherFunction, ParserModule, YnabFile } from '../..'; 3 | import { parse } from '../../util/papaparse'; 4 | 5 | export interface N26Row { 6 | // Fields common to all formats 7 | 'Amount (EUR)': string; 8 | 9 | // Fields for old formats (until 2021) 10 | Date?: string; 11 | Payee?: string; 12 | 'Transaction type'?: string; 13 | 'Payment reference'?: string; 14 | Category?: string; 15 | 'Amount (Foreign Currency)'?: string; 16 | 'Type Foreign Currency'?: string; 17 | 'Exchange Rate'?: string; 18 | 19 | // Fields for formats since 2022 20 | // (If any new fields were introduced, they would be added here) 21 | 22 | // Fields for 2024 format 23 | 'Booking Date'?: string; 24 | 'Value Date'?: string; 25 | 'Partner Name'?: string; 26 | 'Partner Iban'?: string; 27 | Type?: string; 28 | 'Payment Reference'?: string; 29 | 'Account Name'?: string; 30 | 'Original Amount'?: string; 31 | 'Original Currency'?: string; 32 | // 'Amount (EUR)' is already included 33 | // 'Exchange Rate' is already included 34 | } 35 | 36 | export const generateYnabDate = (input: string): string => { 37 | // Handle both date formats: 'YYYY-MM-DD' and 'DD.MM.YYYY' 38 | const isoMatch = input.match(/^(\d{4})-(\d{2})-(\d{2})$/); 39 | if (isoMatch) { 40 | const [, year, month, day] = isoMatch; 41 | return `${month}/${day}/${year}`; 42 | } 43 | 44 | const deMatch = input.match(/^(\d{2})\.(\d{2})\.(\d{4})$/); 45 | if (deMatch) { 46 | const [, day, month, year] = deMatch; 47 | return `${month}/${day}/${year}`; 48 | } 49 | 50 | throw new Error('The input is not a valid date. Expected formats: YYYY-MM-DD or DD.MM.YYYY'); 51 | }; 52 | 53 | export const n26Parser: ParserFunction = async (file: File): Promise => { 54 | const { data } = await parse(file, { header: true }); 55 | 56 | const transactions = (data as N26Row[]) 57 | .filter((r) => (r.Date || r['Booking Date']) && r['Amount (EUR)']) 58 | .map((r) => { 59 | let date = ''; 60 | let payee = ''; 61 | let memo = ''; 62 | let category: string | undefined = undefined; 63 | 64 | const isOldFormat = Boolean(r.Date); 65 | const isNewFormat = Boolean(r['Booking Date']); 66 | 67 | // Determine the format 68 | if (isOldFormat) { 69 | // Old format 70 | date = r.Date; 71 | payee = r.Payee || ''; 72 | memo = r['Payment reference'] || ''; 73 | category = r.Category || undefined; 74 | } else if (isNewFormat) { 75 | // 2024 format 76 | date = r['Booking Date']; 77 | payee = r['Partner Name'] || ''; 78 | memo = r['Payment Reference'] || ''; 79 | // No category in 2024 format 80 | } else { 81 | // Unknown format 82 | throw new Error('Unknown transaction format'); 83 | } 84 | 85 | return { 86 | Date: generateYnabDate(date), 87 | Payee: payee, 88 | Category: category, 89 | Memo: memo, 90 | Outflow: Number(r['Amount (EUR)']) < 0 ? Math.abs(Number(r['Amount (EUR)'])).toFixed(2) : undefined, 91 | Inflow: Number(r['Amount (EUR)']) > 0 ? Number(r['Amount (EUR)']).toFixed(2) : undefined, 92 | }; 93 | }); 94 | 95 | return [{ data: transactions }]; 96 | }; 97 | 98 | export const n26Matcher: MatcherFunction = async (file: File): Promise => { 99 | const { data } = await parse(file, { header: true }); 100 | 101 | if (data.length === 0) { 102 | return false; 103 | } 104 | 105 | const keys = Object.keys(data[0]); 106 | 107 | // Check for old format headers 108 | const oldFormatHeaders = ['Date', 'Payee', 'Transaction type', 'Payment reference', 'Amount (EUR)']; 109 | const isOldFormat = oldFormatHeaders.every((header) => keys.includes(header)); 110 | 111 | // Check for 2024 format headers 112 | const format2024Headers = ['Booking Date', 'Partner Name', 'Type', 'Payment Reference', 'Amount (EUR)']; 113 | const isFormat2024 = format2024Headers.every((header) => keys.includes(header)); 114 | 115 | return isOldFormat || isFormat2024; 116 | }; 117 | 118 | export const n26: ParserModule = { 119 | name: 'N26', 120 | country: 'de', 121 | fileExtension: 'csv', 122 | filenamePattern: /^n26-csv-transactions\.csv$|^[A-Za-z0-9_]+_\d{4}-\d{2}-\d{2}_\d{4}-\d{2}-\d{2}\.csv$/i, 123 | link: 'https://support.n26.com/en-eu/payments-transfers-and-withdrawals/payments-and-transfers/how-to-export-a-list-of-my-transactions', 124 | match: n26Matcher, 125 | parse: n26Parser, 126 | }; -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/n26/test-data/n26-csv-transactions-2021.csv: -------------------------------------------------------------------------------- 1 | "Date","Payee","Account number","Transaction type","Payment reference","Category","Amount (EUR)","Amount (Foreign Currency)","Type Foreign Currency","Exchange Rate" 2 | "2019-01-01","Jan Nordmann","","Outgoing Transfer","Netflix","Subscriptions & Donations","-3.0","","","" 3 | "2019-01-02","ITUNES.COM/BILL","","MasterCard Payment","","Media & Electronics","-1.0","-1.0","EUR","1.0" 4 | "2019-01-02","ITUNES.COM/BILL","","MasterCard Payment","","Media & Electronics","1.0","1.0","EUR","1.0" 5 | "2019-01-02","Leo Bernard","","Direct Debit","Lastschrift Test","Miscellaneous","-180.0","","","" 6 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/n26/test-data/n26-csv-transactions-2022.csv: -------------------------------------------------------------------------------- 1 | Date;Payee;Payee;Transaction type;Payment reference;Amount (EUR);Amount (Foreign Currency);Type Foreign Currency;Exchange Rate 2 | 2022-01-17;;;Income;N26 Cashback;0.74;;; 3 | 2022-01-17;Sample data;;MasterCard Payment;-;-8.4;-8.4;EUR;1.0 4 | 2022-01-17;Sample data;;MasterCard Payment;;-10.0;-10.0;EUR;1.0 -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/outbank/outbank.spec.ts: -------------------------------------------------------------------------------- 1 | import { generateYnabDate, outbank } from './outbank'; 2 | import { YnabFile } from '../..'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | 6 | const content = fs.readFileSync( 7 | path.join(__dirname, 'test-data/Outbank_Export_20190403.csv'), 8 | ); 9 | 10 | const ynabResult: YnabFile[] = [ 11 | { 12 | accountName: 'ing-diba-frankfurt-am-main', 13 | data: [ 14 | { 15 | Date: '04/02/2019', 16 | Payee: 'SHELL', 17 | Category: 'Travel', 18 | Memo: 'SHELL', 19 | Outflow: '44.98', 20 | Inflow: undefined, 21 | }, 22 | { 23 | Date: '04/02/2019', 24 | Payee: 'Musikschule', 25 | Category: '', 26 | Memo: 'Gesang', 27 | Outflow: '109.00', 28 | Inflow: undefined, 29 | }, 30 | ], 31 | }, 32 | ]; 33 | 34 | describe('Outbank Parser Module', () => { 35 | describe('Matcher', () => { 36 | it('should match Outbank files by file name', async () => { 37 | const fileName = 'Outbank_Export_20190403.csv'; 38 | const result = !!fileName.match(outbank.filenamePattern); 39 | expect(result).toBe(true); 40 | }); 41 | 42 | it('should not match other files by file name', async () => { 43 | const invalidFile = new File([], 'test.csv'); 44 | const result = await outbank.match(invalidFile); 45 | expect(result).toBe(false); 46 | }); 47 | 48 | it('should match Outbank files by fields', async () => { 49 | const file = new File([content], 'test.csv'); 50 | const result = await outbank.match(file); 51 | expect(result).toBe(true); 52 | }); 53 | 54 | it('should not match empty files', async () => { 55 | const file = new File([], 'test.csv'); 56 | const result = await outbank.match(file); 57 | expect(result).toBe(false); 58 | }); 59 | }); 60 | 61 | describe('Parser', () => { 62 | it('should parse data correctly', async () => { 63 | const file = new File([content], 'test.csv'); 64 | const result = await outbank.parse(file); 65 | expect(result).toEqual(ynabResult); 66 | }); 67 | }); 68 | 69 | describe('Date Converter', () => { 70 | it('should throw an error when the input date is incorrect', () => { 71 | expect(() => generateYnabDate('1.1.1')).toThrow('not a valid date'); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/outbank/outbank.ts: -------------------------------------------------------------------------------- 1 | import 'mdn-polyfills/String.prototype.startsWith'; 2 | import { ParserFunction, MatcherFunction, ParserModule, YnabRow } from '../..'; 3 | import { parse } from '../../util/papaparse'; 4 | import iban from 'iban'; 5 | import slugify from 'slugify'; 6 | 7 | export interface OutbankRow { 8 | '#': string; 9 | Account?: string; 10 | Date?: string; 11 | 'Value Date'?: string; 12 | Amount?: string; 13 | Currency?: string; 14 | Name?: string; 15 | Number?: string; 16 | Bank?: string; 17 | Reason?: string; 18 | Category?: string; 19 | Subcategory?: string; 20 | Tags?: string; 21 | Note?: string; 22 | 'Bank name'?: string; 23 | 'Ultimate Receiver Name'?: string; 24 | 'Original Amount'?: string; 25 | 'Compensation Amount'?: string; 26 | 'Exchange Rate'?: string; 27 | 'Posting Key'?: string; 28 | 'Posting Text'?: string; 29 | 'Purpose Code'?: string; 30 | 'SEPA Reference'?: string; 31 | 'Client Reference'?: string; 32 | 'Mandate Identification'?: string; 33 | 'Originator Identifier'?: string; 34 | } 35 | 36 | export const generateYnabDate = (input: string) => { 37 | const match = input.match(/(\d{1,2})\/(\d{1,2})\/(\d{1,2})/); 38 | 39 | if (!match) { 40 | throw new Error('The input is not a valid date. Expected format: M/D/Y'); 41 | } 42 | 43 | const [, month, day, year] = match; 44 | return [month.padStart(2, '0'), day.padStart(2, '0'), `20${year}`].join('/'); 45 | }; 46 | 47 | export const parseNumber = (input: string) => Number(input.replace(',', '.')); 48 | 49 | export const outbankParser: ParserFunction = async (file: File) => { 50 | const { data } = await parse(file, { header: true }); 51 | const banks = (await import('./blz.json')).default; 52 | 53 | const groupedData = (data as OutbankRow[]) 54 | .filter(r => r.Date && r.Amount) 55 | .reduce( 56 | (acc, cur) => { 57 | const data = { 58 | Date: generateYnabDate(cur.Date!), 59 | Payee: cur.Name || cur.Reason, 60 | Category: cur.Category, 61 | Memo: cur.Reason, 62 | Outflow: 63 | parseNumber(cur.Amount!) < 0 64 | ? Math.abs(parseNumber(cur.Amount!)).toFixed(2) 65 | : undefined, 66 | Inflow: 67 | parseNumber(cur.Amount!) > 0 68 | ? parseNumber(cur.Amount!).toFixed(2) 69 | : undefined, 70 | }; 71 | 72 | const key = cur.Account || 'no-account'; 73 | 74 | if (Object.keys(acc).includes(key)) { 75 | acc[key].push(data); 76 | } else { 77 | acc[key] = [data]; 78 | } 79 | 80 | return acc; 81 | }, 82 | {} as { [k: string]: YnabRow[] }, 83 | ); 84 | 85 | const getBankSlug = (key: string) => 86 | banks[key.substr(4, 8)] 87 | ? slugify(banks[key.substr(4, 8)]).toLowerCase() 88 | : 'unknown'; 89 | 90 | return Object.keys(groupedData).map(key => ({ 91 | accountName: iban.isValid(key) ? getBankSlug(key) : key, 92 | data: groupedData[key], 93 | })); 94 | }; 95 | 96 | export const outbankMatcher: MatcherFunction = async (file: File) => { 97 | const requiredKeys = [ 98 | '#', 99 | 'Account', 100 | 'Date', 101 | 'Value Date', 102 | 'Amount', 103 | 'Currency', 104 | 'Name', 105 | 'Number', 106 | 'Bank', 107 | 'Reason', 108 | 'Category', 109 | ]; 110 | 111 | const { data } = await parse(file, { header: true }); 112 | 113 | if (data.length === 0) { 114 | return false; 115 | } 116 | 117 | const keys = Object.keys(data[0]); 118 | const missingKeys = requiredKeys.filter(k => !keys.includes(k)); 119 | 120 | if (missingKeys.length === 0) { 121 | return true; 122 | } 123 | 124 | return false; 125 | }; 126 | 127 | export const outbank: ParserModule = { 128 | name: 'Outbank', 129 | country: 'de', 130 | fileExtension: 'csv', 131 | filenamePattern: /^Outbank_Export_(\d{8})\.csv/, 132 | link: 133 | 'https://help.outbankapp.com/en/kb/articles/wie-kann-ich-ums-tze-als-csv-datei-exportieren', 134 | match: outbankMatcher, 135 | parse: outbankParser, 136 | }; 137 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/outbank/test-data/Outbank_Export_20190403.csv: -------------------------------------------------------------------------------- 1 | #;Account;Date;Value Date;Amount;Currency;Name;Number;Bank;Reason;Category;Subcategory;Tags;Note;Bank name;Ultimate Receiver Name;Original Amount;Compensation Amount;Exchange Rate;Posting Key;Posting Text;Purpose Code;SEPA Reference;Client Reference;Mandate Identification;Originator Identifier 2 | 1;DE12500105170648489890;4/2/19;;-44,98;EUR;;;;"SHELL";"Travel";"Gas Station";;;;;"-44,98";;"1";;;;;;; 3 | 2;DE12500105170648489890;4/2/19;;-109,00;EUR;"Musikschule";"";"";"Gesang";;;;;;;;;;;;;;;; -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/volksbank-eg/test-data/Umsaetze_test_2019.06.19.csv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-parsers/src/de/volksbank-eg/test-data/Umsaetze_test_2019.06.19.csv -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/volksbank-eg/volksbank-eg.spec.ts: -------------------------------------------------------------------------------- 1 | import { generateYnabDate, volksbankEG } from './volksbank-eg'; 2 | import { YnabFile } from '../..'; 3 | import { encode } from 'iconv-lite'; 4 | 5 | const content = encode( 6 | `Volksbank eG;;;;;;;;;;;; 7 | ;;;;;;;;;;;; 8 | Umsatzanzeige;;;;;;;;;;;; 9 | ;;;;;;;;;;;; 10 | BLZ:;42461435;;Datum:;04.04.2019;;;;;;;; 11 | Konto:;1008800049;;Uhrzeit:;22:57:16;;;;;;;; 12 | Abfrage von:;Hermann Testkunde;;Kontoinhaber:;Hermann Testkunde;;;;;;;; 13 | ;;;;;;;;;;;; 14 | Zeitraum:;;von:;28.03.2019;bis:;;;;;;;; 15 | Betrag in EUR:;;von:; ;bis:; ;;;;;;; 16 | Sortiert nach:;Buchungstag;absteigend;;;;;;;;;; 17 | ;;;;;;;;;;;; 18 | Buchungstag;Valuta;Auftraggeber/Zahlungsempfänger;Empfänger/Zahlungspflichtiger;Konto-Nr.;IBAN;BLZ;BIC;Vorgang/Verwendungszweck;Kundenreferenz;Währung;Umsatz; 19 | 03.04.2019;04.04.2019;Hermann Testkunde;Gartenbauverein;12345678;;70190000;;"ÜBERWEISUNG 20 | Quartalsbeitrag Gartenbauverein 21 | Musterstadt 73 e.V. 22 | vom 03.03.2005 23 | Verwendete TAN: 123456";;EUR;12;S 24 | 03.04.2019;04.04.2019;Hermann Testkunde;Hermann Testkunde;;;;;"ÜBERTRAG 25 | Hermann Testkunde 26 | UEBERTRAG VOM ANLAGEKONTO";;EUR;112;H 27 | 03.04.2019;04.04.2019;Hermann Testkunde;;;;;;"ÜBERWEISUNG 28 | Kaufstadt Lebensmittel 29 | Vielen Dank fuer Ihren Einkauf";;EUR;258,17;S 30 | ;;;;;;;;;;;; 31 | 28.03.2019;;;;;;;;;Anfangssaldo;EUR;22.257,11;H 32 | 04.04.2019;;;;;;;;;Endsaldo;EUR;21.488,94;H 33 | `, 34 | 'win1252', 35 | ); 36 | 37 | const ynabResult: YnabFile[] = [ 38 | { 39 | data: [ 40 | { 41 | Date: '04/04/2019', 42 | Inflow: '12.00', 43 | Memo: 'Quartalsbeitrag Gartenbauverein Musterstadt 73 e.V. vom 03.03.2005', 44 | Outflow: undefined, 45 | Payee: 'Gartenbauverein', 46 | }, 47 | { 48 | Date: '04/04/2019', 49 | Inflow: '112.00', 50 | Memo: 'Hermann Testkunde UEBERTRAG VOM ANLAGEKONTO', 51 | Outflow: undefined, 52 | Payee: 'Hermann Testkunde', 53 | }, 54 | { 55 | Date: '04/04/2019', 56 | Inflow: '258.17', 57 | Memo: 'Kaufstadt Lebensmittel Vielen Dank fuer Ihren Einkauf', 58 | Outflow: undefined, 59 | Payee: '', 60 | }, 61 | ], 62 | }, 63 | ]; 64 | 65 | describe('Volksbank Parser Module', () => { 66 | describe('Matcher', () => { 67 | it('should match Volksbank files by file name', async () => { 68 | const fileName = 'Umsaetze_DE84000099000008800049_2019.04.04.csv'; 69 | const result = !!fileName.match(volksbankEG.filenamePattern); 70 | expect(result).toBe(true); 71 | }); 72 | 73 | it('should not match other files by file name', async () => { 74 | const invalidFile = new File([], 'test.csv'); 75 | const result = await volksbankEG.match(invalidFile); 76 | expect(result).toBe(false); 77 | }); 78 | 79 | it('should match Volksbank files by fields', async () => { 80 | const file = new File([content], 'test.csv'); 81 | const result = await volksbankEG.match(file); 82 | expect(result).toBe(true); 83 | }); 84 | 85 | it('should not match empty files', async () => { 86 | const file = new File([], 'test.csv'); 87 | const result = await volksbankEG.match(file); 88 | expect(result).toBe(false); 89 | }); 90 | }); 91 | 92 | describe('Parser', () => { 93 | it('should parse data correctly', async () => { 94 | const file = new File([content], 'test.csv'); 95 | const result = await volksbankEG.parse(file); 96 | expect(result).toEqual(ynabResult); 97 | }); 98 | }); 99 | 100 | describe('Date Converter', () => { 101 | it('should format an input date correctly', () => { 102 | expect(generateYnabDate('03.05.2018')).toEqual('05/03/2018'); 103 | }); 104 | 105 | it('should throw an error when the input date is incorrect', () => { 106 | expect(() => generateYnabDate('1.1.1')).toThrow('not a valid date'); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/de/volksbank-eg/volksbank-eg.ts: -------------------------------------------------------------------------------- 1 | import 'mdn-polyfills/String.prototype.startsWith'; 2 | import { ParserFunction, MatcherFunction, ParserModule } from '../..'; 3 | import { parse } from '../../util/papaparse'; 4 | import { readEncodedFile } from '../../util/read-encoded-file'; 5 | 6 | export interface VolksbankRow { 7 | Buchungstag: string; 8 | Valuta: string; 9 | 'Auftraggeber/Zahlungsempfänger': string; 10 | 'Empfänger/Zahlungspflichtiger': string; 11 | 'Konto-Nr.': string; 12 | IBAN: string; 13 | BLZ: string; 14 | BIC: string; 15 | 'Vorgang/Verwendungszweck': string; 16 | Kundenreferenz: string; 17 | Währung: string; 18 | Umsatz: string; 19 | } 20 | 21 | export const generateYnabDate = (input: string) => { 22 | const match = input.match(/(\d{2})\.(\d{2})\.(\d{4})/); 23 | 24 | if (!match) { 25 | throw new Error('The input is not a valid date. Expected format: YYYY-MM-DD'); 26 | } 27 | 28 | const [, day, month, year] = match; 29 | return [month.padStart(2, '0'), day.padStart(2, '0'), year].join('/'); 30 | }; 31 | 32 | export const parseNumber = (input: string) => Number(input.replace(',', '.')); 33 | 34 | export const trimMetaData = (input: string) => { 35 | const beginning = input.indexOf('Buchungstag;Valuta;'); 36 | const end = input.lastIndexOf('\n;;;;'); 37 | 38 | if (beginning === -1 || end === -1) { 39 | throw new Error( 40 | 'Metadata could not be trimmed because the file format is incorrect.', 41 | ); 42 | } 43 | 44 | const res = input 45 | .substr(beginning, input.length - beginning - (input.length - end)) 46 | .trim(); 47 | 48 | return res; 49 | }; 50 | 51 | export const sanitizeMemo = (input: string) => { 52 | return input 53 | .split('\n') 54 | .slice(1) 55 | .filter(r => !r.startsWith('Verwendete TAN:')) 56 | .join(' '); 57 | }; 58 | 59 | export const volksbankParser: ParserFunction = async (file: File) => { 60 | const fileString = trimMetaData(await readEncodedFile(file)); 61 | const { data } = await parse(fileString, { header: true }); 62 | 63 | return [ 64 | { 65 | data: (data as VolksbankRow[]) 66 | .filter(r => r.Valuta && r.Umsatz) 67 | .map(r => ({ 68 | Date: generateYnabDate(r.Valuta), 69 | Payee: r['Empfänger/Zahlungspflichtiger'], 70 | Memo: sanitizeMemo(r['Vorgang/Verwendungszweck']), 71 | Outflow: 72 | parseNumber(r.Umsatz) < 0 73 | ? (-parseNumber(r.Umsatz)).toFixed(2) 74 | : undefined, 75 | Inflow: 76 | parseNumber(r.Umsatz) > 0 ? parseNumber(r.Umsatz).toFixed(2) : undefined, 77 | })), 78 | }, 79 | ]; 80 | }; 81 | 82 | export const volksbankMatcher: MatcherFunction = async (file: File) => { 83 | const requiredKeys: (keyof VolksbankRow)[] = [ 84 | 'Auftraggeber/Zahlungsempfänger', 85 | 'BIC', 86 | 'BLZ', 87 | 'Buchungstag', 88 | 'Empfänger/Zahlungspflichtiger', 89 | 'IBAN', 90 | 'Konto-Nr.', 91 | 'Kundenreferenz', 92 | 'Umsatz', 93 | 'Valuta', 94 | 'Vorgang/Verwendungszweck', 95 | 'Währung', 96 | ]; 97 | 98 | const rawFileString = await readEncodedFile(file); 99 | 100 | if (rawFileString.startsWith('Volksbank eG;;;;;;;;;;;;')) { 101 | return true; 102 | } 103 | 104 | try { 105 | const { data } = await parse(trimMetaData(rawFileString), { header: true }); 106 | 107 | if (data.length === 0) { 108 | return false; 109 | } 110 | 111 | const keys = Object.keys(data[0]); 112 | const missingKeys = requiredKeys.filter(k => !keys.includes(k)); 113 | 114 | if (missingKeys.length === 0) { 115 | return true; 116 | } 117 | } catch (e) { 118 | return false; 119 | } 120 | 121 | return false; 122 | }; 123 | 124 | export const volksbankEG: ParserModule = { 125 | name: 'Volksbank', 126 | country: 'de', 127 | fileExtension: 'csv', 128 | filenamePattern: /Umsaetze_(.+?)_\d{4}\.\d{2}\.\d{2}\.csv/, 129 | link: 'https://www.volksbank-eg.de/privatkunden.html', 130 | match: volksbankMatcher, 131 | parse: volksbankParser, 132 | }; 133 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/gr/piraeus/piraeus.spec.ts: -------------------------------------------------------------------------------- 1 | import { generateYnabDate, piraeus } from './piraeus'; 2 | import { YnabFile } from '../..'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | 6 | const content = fs.readFileSync( 7 | path.join(__dirname, 'test-data/Account.Transactions_20200725.xlsx'), 8 | ); 9 | 10 | const ynabResult: YnabFile[] = [ 11 | { 12 | data: [ 13 | { 14 | Category: 'Restaurants', 15 | Date: '07/25/2020', 16 | Memo: 'KAPETANAKIS IOANNIS THESSALONIKI(DEBIT PURCHASE AUTHORIZATION)', 17 | Inflow: undefined, 18 | Outflow: 13, 19 | }, 20 | { 21 | Category: 'Supermarket', 22 | Date: '07/24/2020', 23 | Memo: 'GOYSIOS ARTOZAXAROPLAS ETHN ANTISTA(DEBIT PURCHASE AUTHORIZATION)', 24 | Inflow: undefined, 25 | Outflow: 2, 26 | }, 27 | { 28 | Category: 'Restaurants', 29 | Date: '07/24/2020', 30 | Memo: 'E FOOD GR IRAKLEIO (CARD PURCHASE)', 31 | Inflow: undefined, 32 | Outflow: 18, 33 | }, 34 | { 35 | Category: 'Supermarket', 36 | Date: '07/23/2020', 37 | Memo: 'AB VASILOPOULOS S.A. KALAMARIA (DEBIT PURCHASE AUTHORIZATION)', 38 | Inflow: undefined, 39 | Outflow: 33.49, 40 | }, 41 | { 42 | Category: 'Pharmacy', 43 | Date: '07/23/2020', 44 | Memo: 'ALISE ALIKI GKERMANI KALAMARIA THE (CARD PURCHASE)', 45 | Inflow: undefined, 46 | Outflow: 6.35, 47 | }, 48 | { 49 | Category: 'Supermarket', 50 | Date: '07/22/2020', 51 | Memo: 'GOYSIOS ARTOZAXAROPLAS ETHN ANTISTA(DEBIT PURCHASE AUTHORIZATION)', 52 | Inflow: undefined, 53 | Outflow: 2, 54 | }, 55 | { 56 | Category: 'Day to day', 57 | Date: '07/22/2020', 58 | Memo: 'WWW.OSMOSHOP.GR THESSALONIKI (CARD PURCHASE)', 59 | Inflow: undefined, 60 | Outflow: 52.2, 61 | }, 62 | { 63 | Category: 'Income', 64 | Date: '07/21/2020', 65 | Memo: 'Νετφλιξ Ιουλίου (TRF.FROM THIRD PARTY ACC.)', 66 | Inflow: 7, 67 | Outflow: undefined, 68 | }, 69 | { 70 | Category: 'Equipment', 71 | Date: '07/21/2020', 72 | Memo: 'LEROY MERLIN SGB SA PIL PYLAIA (CARD PURCHASE)', 73 | Inflow: undefined, 74 | Outflow: 10.58, 75 | }, 76 | { 77 | Category: 'Equipment', 78 | Date: '07/21/2020', 79 | Memo: 'LEROY MERLIN SGB SA PIL PYLAIA (CARD PURCHASE)', 80 | Inflow: undefined, 81 | Outflow: 10.07, 82 | }, 83 | { 84 | Category: 'Entertainment', 85 | Date: '07/21/2020', 86 | Memo: 'PAYPAL NETFLIX COM 35314369001 (CARD PURCHASE)', 87 | Inflow: undefined, 88 | Outflow: 13.99, 89 | }, 90 | { 91 | Category: 'Car service', 92 | Date: '07/21/2020', 93 | Memo: 'J K P AVAX IKTEO PILEA (CARD PURCHASE)', 94 | Inflow: undefined, 95 | Outflow: 5, 96 | }, 97 | { 98 | Category: 'Reallocated', 99 | Date: '07/21/2020', 100 | Memo: '5273060686186 (TRANSFER TO ACCOUNT)', 101 | Inflow: undefined, 102 | Outflow: 129.99, 103 | }, 104 | { 105 | Category: 'Transfers', 106 | Date: '07/21/2020', 107 | Memo: '5237024209987 (TRANS.TO THIRD PARTY ACC.)', 108 | Inflow: undefined, 109 | Outflow: 24, 110 | }, 111 | { 112 | Category: 'Bank fees', 113 | Date: '07/20/2020', 114 | Memo: 'ΠΡΟΜΗΘΕΙΑ ΕΜΒΑΣΜΑΤΟΣ (TRANSFER COMMISSION)', 115 | Inflow: undefined, 116 | Outflow: 4, 117 | }, 118 | { 119 | Category: 'Income', 120 | Date: '07/20/2020', 121 | Memo: 'B/O WORLDPAY AP LTD (INCOMING TRANSFER)', 122 | Inflow: 130.73, 123 | Outflow: undefined, 124 | }, 125 | { 126 | Category: 'Bank fees', 127 | Date: '07/20/2020', 128 | Memo: 'ΖΕΝΙΘ ΡΕΥΜΑ (BILL PAYMENT COMMISSION)', 129 | Inflow: undefined, 130 | Outflow: 0.6, 131 | }, 132 | { 133 | Category: 'Energy', 134 | Date: '07/20/2020', 135 | Memo: 'ΖΕΝΙΘ ΡΕΥΜΑ (BILL PAYMENT)', 136 | Inflow: undefined, 137 | Outflow: 25.67, 138 | }, 139 | ], 140 | }, 141 | ]; 142 | 143 | describe('Piraeus Bank Parser Module', () => { 144 | describe('Matcher', () => { 145 | it('should match Piraeus Bank files by file name', async () => { 146 | const fileName = 'Account Transactions_20190601.xlsx'; 147 | const result = !!fileName.match(piraeus.filenamePattern); 148 | expect(result).toBe(true); 149 | }); 150 | 151 | it('should not match other files by file name', async () => { 152 | const invalidFile = new File([], 'test.xlsx'); 153 | const result = await piraeus.match(invalidFile); 154 | expect(result).toBe(false); 155 | }); 156 | 157 | it('should match Piraeus Bank files by fields', async () => { 158 | const file = new File([content], 'test.xlsx'); 159 | const result = await piraeus.match(file); 160 | expect(result).toBe(true); 161 | }); 162 | }); 163 | 164 | describe('Parser', () => { 165 | it('should parse data correctly', async () => { 166 | const file = new File([content], 'test.xlsx'); 167 | const result = await piraeus.parse(file); 168 | expect(result).toEqual(ynabResult); 169 | }); 170 | }); 171 | 172 | describe('Date Converter', () => { 173 | it('should format an input date correctly', () => { 174 | expect(generateYnabDate('03/05/2018')).toEqual('05/03/2018'); 175 | }); 176 | 177 | it('should throw an error when the input date is incorrect', () => { 178 | expect(() => generateYnabDate('1.1.1')).toThrow('not a valid date'); 179 | }); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/gr/piraeus/piraeus.ts: -------------------------------------------------------------------------------- 1 | import 'mdn-polyfills/String.prototype.startsWith'; 2 | import { ParserFunction, MatcherFunction, ParserModule, YnabRow } from '../..'; 3 | import { CellObject } from 'xlsx/types'; 4 | import { readToBuffer } from '../../util/read-to-buffer'; 5 | 6 | export const generateYnabDate = (input: string) => { 7 | const match = input.match(/(\d{2})\/(\d{2})\/(\d{4})/); 8 | 9 | if (!match) { 10 | throw new Error( 11 | 'The input is not a valid date. Expected format: DD/MM/YYYY, got ' + input, 12 | ); 13 | } 14 | 15 | const [, day, month, year] = match; 16 | return [month.padStart(2, '0'), day.padStart(2, '0'), year].join('/'); 17 | }; 18 | 19 | export const parseNumber = (input: string) => Number(input.replace(',', '.')); 20 | 21 | export const cleanString = (input: string) => input.replace(/\s+/g, ' ').trim(); 22 | 23 | export const piraeusParser: ParserFunction = async (file: File) => { 24 | const xlsx = await import('xlsx'); 25 | const workbook = xlsx.read(await readToBuffer(file), { 26 | type: 'buffer', 27 | }); 28 | 29 | const sheet = workbook.Sheets[workbook.SheetNames[0]]; 30 | const rows: YnabRow[] = []; 31 | 32 | for (let rowNum = 5; true; rowNum++) { 33 | let category: CellObject | undefined = sheet[`A${rowNum}`]; 34 | if (!category || category.t === 'e' || String(category.v).trim() === '') { 35 | break; 36 | } 37 | 38 | rows.push({ 39 | Category: cleanString(String(category.v)), 40 | Date: generateYnabDate(String(sheet[`C${rowNum}`].v)), 41 | Memo: cleanString(String(sheet[`B${rowNum}`].v).split('\r')[0]), 42 | Inflow: sheet[`E${rowNum}`].v > 0 ? sheet[`E${rowNum}`].v : undefined, 43 | Outflow: sheet[`E${rowNum}`].v < 0 ? -sheet[`E${rowNum}`].v : undefined, 44 | }); 45 | } 46 | 47 | return [ 48 | { 49 | data: rows, 50 | }, 51 | ]; 52 | }; 53 | 54 | export const piraeusMatcher: MatcherFunction = async (file: File) => { 55 | try { 56 | const xlsx = await import('xlsx'); 57 | const workbook = xlsx.read(await readToBuffer(file), { 58 | type: 'buffer', 59 | }); 60 | 61 | const cell: CellObject = workbook.Sheets[workbook.SheetNames[0]]['A1']; 62 | return cell.v === 'Piraeus Bank'; 63 | } catch (e) { 64 | return false; 65 | } 66 | }; 67 | 68 | export const piraeus: ParserModule = { 69 | name: 'Piraeus Bank', 70 | country: 'gr', 71 | fileExtension: 'xlsx', 72 | filenamePattern: /Account Transactions_\d{8}\.xlsx/, 73 | link: 'https://www.piraeusbank.gr', 74 | match: piraeusMatcher, 75 | parse: piraeusParser, 76 | }; 77 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/gr/piraeus/test-data/Account.Transactions_20190601.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-parsers/src/gr/piraeus/test-data/Account.Transactions_20190601.xlsx -------------------------------------------------------------------------------- /packages/ynap-parsers/src/gr/piraeus/test-data/Account.Transactions_20200725.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-parsers/src/gr/piraeus/test-data/Account.Transactions_20200725.xlsx -------------------------------------------------------------------------------- /packages/ynap-parsers/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { matchFile } from '.'; 2 | import glob from 'fast-glob'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | 6 | describe('Main Module', () => { 7 | it('should match only one parser for each test file', async () => { 8 | const files = await glob('**/test-data/*', { absolute: true, cwd: __dirname }); 9 | for (const fileName of files) { 10 | const file = new File([fs.readFileSync(fileName)], path.basename(fileName)); 11 | const matches = await matchFile(file); 12 | 13 | if (matches.length === 0) { 14 | console.error('No parsers for', fileName); 15 | } 16 | 17 | if (matches.length > 1) { 18 | console.warn('Multiple parsers for', fileName); 19 | console.warn(matches.map((m) => m.name)); 20 | } 21 | expect(matches.length).toBeGreaterThanOrEqual(1); 22 | } 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/index.ts: -------------------------------------------------------------------------------- 1 | import { unparse } from 'papaparse'; 2 | import 'mdn-polyfills/String.prototype.padStart'; 3 | 4 | import uniq from 'lodash/uniq'; 5 | import last from 'lodash/last'; 6 | 7 | import { outbank } from './de/outbank/outbank'; 8 | import { _1822direkt } from './de/1822direkt/1822direkt'; 9 | import { n26 } from './de/n26/n26'; 10 | import { revolut } from './international/revolut/revolut'; 11 | import { ingDiBa } from './de/ing-diba/ing-diba'; 12 | import { comdirect } from './de/comdirect/comdirect'; 13 | import { kontist } from './de/kontist/kontist'; 14 | import { volksbankEG } from './de/volksbank-eg/volksbank-eg'; 15 | 16 | import { ingAustria } from './at/ing/ing-austria'; 17 | import { bancomer } from './mx/bbva-bancomer/bbva-bancomer'; 18 | import { piraeus } from './gr/piraeus/piraeus'; 19 | import { marcus } from './uk/marcus/marcus'; 20 | import { aqua } from './uk/aqua/aqua'; 21 | 22 | import { bank2ynab } from './bank2ynab/bank2ynab'; 23 | import { sparbankenTanum as sparbankenTanum2018 } from './se/sparbanken-tanum/2018/sparbanken-tanum'; 24 | import { sparbankenTanum as sparbankenTanum2019 } from './se/sparbanken-tanum/2019/sparbanken-tanum'; 25 | import { mt940 } from './international/mt940/mt940'; 26 | 27 | import { mbank } from './pl/mbank/mbank'; 28 | import { bankPocztowy } from './pl/bank-pocztowy/bank-pocztowy'; 29 | import { seb } from './se/seb-privat/seb'; 30 | 31 | export interface YnabRow { 32 | Date?: string; 33 | Payee?: string; 34 | Category?: string; 35 | Memo?: string; 36 | Outflow?: number | string; 37 | Inflow?: number | string; 38 | } 39 | 40 | export interface YnabFile { 41 | accountName?: string; 42 | data: YnabRow[]; 43 | } 44 | 45 | export interface ParserModule { 46 | name: string; 47 | country: string; 48 | link: string; 49 | fileExtension: string; 50 | filenamePattern: RegExp; 51 | match: MatcherFunction; 52 | parse: ParserFunction; 53 | } 54 | 55 | export type MatcherFunction = (file: File) => Promise; 56 | export type ParserFunction = (file: File) => Promise; 57 | 58 | export const parsers: ParserModule[] = [ 59 | // AT 60 | ingAustria, 61 | 62 | // DE 63 | outbank, 64 | n26, 65 | ingDiBa, 66 | comdirect, 67 | kontist, 68 | volksbankEG, 69 | _1822direkt, 70 | 71 | // GR 72 | piraeus, 73 | 74 | // MX 75 | bancomer, 76 | 77 | // SE 78 | sparbankenTanum2018, 79 | sparbankenTanum2019, 80 | seb, 81 | 82 | // UK 83 | marcus, 84 | aqua, 85 | 86 | // PL 87 | mbank, 88 | bankPocztowy, 89 | 90 | // International 91 | revolut, 92 | mt940, 93 | ...bank2ynab, 94 | ]; 95 | 96 | export const countries = uniq( 97 | parsers.filter(p => p.country.length === 2).map(p => p.country), 98 | ); 99 | 100 | export const matchFile = async (file: File): Promise => { 101 | if (file.name.match(/^(.+)-ynap\.csv$/)) { 102 | throw new Error('This file has already been converted by YNAP.'); 103 | } 104 | 105 | const filenameMatches = parsers.filter(p => file.name.match(p.filenamePattern)); 106 | 107 | // If parser modules match the file by its filename, try those first 108 | if (filenameMatches.length > 0) { 109 | const parsers = ( 110 | await Promise.all( 111 | filenameMatches.map(async p => ({ 112 | parser: p, 113 | matched: await p.match(file), 114 | })), 115 | ) 116 | ) 117 | .filter(r => r.matched) 118 | .map(p => p.parser); 119 | 120 | if (parsers.length > 0) { 121 | return parsers; 122 | } 123 | } 124 | 125 | // If they don't, run all matchers against the input file 126 | const results = ( 127 | await Promise.all( 128 | parsers 129 | .filter( 130 | p => 131 | p.fileExtension.toLowerCase() === 132 | last(file.name.split('.')).toLowerCase(), 133 | ) 134 | .map(async p => ({ 135 | parser: p, 136 | matched: await p.match(file), 137 | })), 138 | ) 139 | ) 140 | .filter(r => r.matched) 141 | .map(p => p.parser); 142 | 143 | return results; 144 | }; 145 | 146 | export const parseFile = async (file: File, parserOverride?: ParserModule) => { 147 | let parser: ParserModule | null = null; 148 | 149 | if (parserOverride) { 150 | parser = parserOverride; 151 | } else { 152 | const matches = await matchFile(file); 153 | console.log( 154 | 'The file', 155 | file.name, 156 | 'was matched by', 157 | matches.map(m => m.name).join(', '), 158 | ); 159 | parser = matches.length > 0 ? matches[0] : null; 160 | } 161 | 162 | if (!parser) { 163 | throw new Error(`No parser is available for this file.`); 164 | } 165 | 166 | const ynabData = await parser.parse(file); 167 | 168 | return ynabData.map(f => ({ 169 | ...f, 170 | data: unparse(f.data), 171 | rawData: f.data, 172 | matchedParser: parser, 173 | })); 174 | }; 175 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/international/mt940/mt940.spec.ts: -------------------------------------------------------------------------------- 1 | import { mt940matcher, mt940parser, generateYnabDate } from './mt940'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import { YnabFile } from '../..'; 5 | 6 | const content = fs.readFileSync(path.join(__dirname, 'test-data/mt940-bunq.sta')); 7 | 8 | const output: YnabFile[] = [ 9 | { 10 | accountName: 'BUNQ BV NL28BUNQ0000000002 EUR', 11 | data: [ 12 | { 13 | Outflow: undefined, 14 | Inflow: 250, 15 | Date: '08/26/2019', 16 | Memo: '/IBAN/DE84100110010000000002/NAME/Name/REMI/Playing money', 17 | }, 18 | { 19 | Outflow: 0.99, 20 | Inflow: undefined, 21 | Date: '08/26/2019', 22 | Memo: 23 | '/NAME/Aral Station 140974148/REMI/Aral Station 140974148 Brueggen, DE', 24 | }, 25 | { 26 | Outflow: 0.01, 27 | Inflow: undefined, 28 | Date: '08/26/2019', 29 | Memo: '/IBAN/NL89BUNQ0000000056/NAME/L.P. Name/REMI/', 30 | }, 31 | ], 32 | }, 33 | ]; 34 | 35 | describe('MT940 Parser Module', () => { 36 | describe('Matcher', () => { 37 | it('should correctly match MT940 files', async () => { 38 | const result = await mt940matcher(new File([content], '')); 39 | expect(result).toBeTruthy(); 40 | }); 41 | 42 | it('should fail to match invalid files', async () => { 43 | const result = await mt940matcher(new File(['test'], '')); 44 | expect(result).toBeFalsy(); 45 | }); 46 | }); 47 | 48 | describe('Parser', () => { 49 | it('should correctly parse MT940 files', async () => { 50 | const result = await mt940parser(new File([content], 'test.sta')); 51 | expect(result).toEqual(output); 52 | }); 53 | }); 54 | 55 | describe('Date Converter', () => { 56 | it('should convert dates correctly', () => { 57 | expect(generateYnabDate('2018-09-01')).toEqual('09/01/2018'); 58 | }); 59 | 60 | it('should throw an error when the input date is incorrect', () => { 61 | expect(() => generateYnabDate('1.1.1')).toThrow('not a valid date'); 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/international/mt940/mt940.ts: -------------------------------------------------------------------------------- 1 | import 'mdn-polyfills/String.prototype.startsWith'; 2 | import { ParserFunction, MatcherFunction, ParserModule, YnabRow } from '../..'; 3 | import { readToBuffer } from '../../util/read-to-buffer'; 4 | import { YnabFile } from '../..'; 5 | 6 | export const generateYnabDate = (input: string) => { 7 | const match = input.match(/(\d{4})-(\d{2})-(\d{2})/); 8 | 9 | if (!match) { 10 | throw new Error('The input is not a valid date. Expected format: YYYY-MM-DD'); 11 | } 12 | 13 | const [, year, month, day] = match; 14 | return [month.padStart(2, '0'), day.padStart(2, '0'), year].join('/'); 15 | }; 16 | 17 | export const mt940matcher: MatcherFunction = async file => { 18 | const mt = await import('mt940-js'); 19 | const buffer = await readToBuffer(file); 20 | 21 | try { 22 | const res = await mt.read(buffer); 23 | return res.length > 0; 24 | } catch {} 25 | 26 | return false; 27 | }; 28 | 29 | export const mt940parser: ParserFunction = async file => { 30 | const mt = await import('mt940-js'); 31 | const buffer = await readToBuffer(file); 32 | const statements = await mt.read(buffer); 33 | 34 | return statements.map( 35 | s => 36 | ({ 37 | accountName: [s.referenceNumber, s.accountId].filter(Boolean).join(' '), 38 | data: s.transactions.map( 39 | t => 40 | ({ 41 | Inflow: t.isCredit ? t.amount : undefined, 42 | Outflow: t.isCredit ? undefined : t.amount, 43 | Date: generateYnabDate(t.entryDate), 44 | Memo: t.description, 45 | } as YnabRow), 46 | ), 47 | } as YnabFile), 48 | ); 49 | }; 50 | 51 | export const mt940: ParserModule = { 52 | name: 'MT940 (standard)', 53 | link: 'https://en.wikipedia.org/wiki/MT940', 54 | country: 'international', 55 | fileExtension: 'sta', 56 | filenamePattern: /(.*)\.sta$/, 57 | match: mt940matcher, 58 | parse: mt940parser, 59 | }; 60 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/international/mt940/test-data/mt940-bunq.sta: -------------------------------------------------------------------------------- 1 | BUNQNL2A 2 | 940 3 | BUNQNL2A 4 | :20:BUNQ BV 5 | :25:NL28BUNQ0000000002 EUR 6 | :28:00001 7 | :60F:D190823EUR0, 8 | :61:1908260826C250,NTRFNONREF 9 | :86:/IBAN/DE84100110010000000002/NAME/Name/REMI/Playing money 10 | :61:1908260826D0,99NMSCNONREF 11 | :86:/NAME/Aral Station 140974148/REMI/Aral Station 140974148 Brueggen, DE 12 | :61:1908260826D0,01NMSCNONREF 13 | :86:/IBAN/NL89BUNQ0000000056/NAME/L.P. Name/REMI/ 14 | :62F:C190828EUR249, 15 | - 16 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/international/revolut/revolut.spec.ts: -------------------------------------------------------------------------------- 1 | import { generateYnabDate, revolut } from './revolut'; 2 | import { YnabFile } from '../..'; 3 | 4 | const content = `Completed Date ; Reference ; Paid Out (RON) ; Paid In (RON) ; Exchange Out; Exchange In ; Balance (RON); Exchange Rate ; Category 5 | 24 Mar 2020 ; Premium plan fee ; 29.99 ; ; ; ; 13.09 ; ; Services 6 | 1 Apr 2019 ; To Piglet ; ; 1.42 ; ; ; 43.08 ; ; Transfers 7 | `; 8 | 9 | const ynabResult: YnabFile[] = [ 10 | { 11 | data: [ 12 | { 13 | Date: '2020-03-24', 14 | Payee: 'Premium plan fee', 15 | Category: 'Services', 16 | Memo: 'Premium plan fee', 17 | Outflow: '29.99', 18 | Inflow: undefined, 19 | }, 20 | { 21 | Date: '2019-04-01', 22 | Payee: 'To Piglet', 23 | Category: 'Transfers', 24 | Memo: 'To Piglet', 25 | Outflow: undefined, 26 | Inflow: '1.42', 27 | }, 28 | ], 29 | }, 30 | ]; 31 | 32 | describe('Revolut Parser Module', () => { 33 | describe('Matcher', () => { 34 | it('should match Revolut files by file name', async () => { 35 | const fileName = 'Revolut-RON-Statement-May 2018 to Apr 2019.csv'; 36 | const result = !!fileName.match(revolut.filenamePattern); 37 | expect(result).toBe(true); 38 | }); 39 | 40 | it('should not match other files by file name', async () => { 41 | const invalidFile = new File([], 'invalid.csv'); 42 | const result = await revolut.match(invalidFile); 43 | expect(result).toBe(false); 44 | }); 45 | 46 | it('should match Revolut files by fields', async () => { 47 | const file = new File([content], 'test.csv'); 48 | const result = await revolut.match(file); 49 | expect(result).toBe(true); 50 | }); 51 | 52 | it('should not match empty files', async () => { 53 | const file = new File([], 'test.csv'); 54 | const result = await revolut.match(file); 55 | expect(result).toBe(false); 56 | }); 57 | }); 58 | 59 | describe('Parser', () => { 60 | it('should parse data correctly', async () => { 61 | const file = new File([content], 'test.csv'); 62 | const result = await revolut.parse(file); 63 | expect(result).toEqual(ynabResult); 64 | }); 65 | }); 66 | 67 | describe('Date Converter', () => { 68 | it('should throw an error when the input date is incorrect', () => { 69 | expect(() => generateYnabDate('1.1.1')).toThrow('Invalid time value'); 70 | }); 71 | 72 | it('should convert dates correctly', () => { 73 | expect(generateYnabDate('12 Feb 2019')).toEqual('2019-02-12'); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/international/revolut/revolut.ts: -------------------------------------------------------------------------------- 1 | import 'mdn-polyfills/String.prototype.startsWith'; 2 | import { parse, format } from 'date-fns'; 3 | 4 | import { ParserFunction, MatcherFunction, ParserModule } from '../..'; 5 | import { parse as parseCsv } from '../../util/papaparse'; 6 | 7 | const COMPLETED_DATE = 0; 8 | const REFERENCE = 1; 9 | const PAID_OUT = 2; 10 | const PAID_IN = 3; 11 | const CATEGORY = 8; 12 | 13 | export const generateYnabDate = (input: string) => { 14 | let match = parse(input, 'dd MMM', Date.now()); 15 | if (!isValidDate(match)) { 16 | match = parse(input, 'dd MMM yyyy', Date.now()); 17 | } 18 | return format(match, 'yyyy-MM-dd'); 19 | }; 20 | 21 | export const revolutParser: ParserFunction = async (file: File) => { 22 | const { data } = await parseCsv(file, { header: false }); 23 | 24 | return [ 25 | { 26 | data: (data as string[][]) 27 | .slice(1) 28 | .filter(r => r[0]) 29 | .map(r => ({ 30 | Date: generateYnabDate(r[COMPLETED_DATE]), 31 | Payee: r[REFERENCE].trim(), 32 | Category: r[CATEGORY].trim(), 33 | Memo: r[REFERENCE].trim(), 34 | Outflow: r[PAID_OUT].trim() ? Number(r[PAID_OUT]).toFixed(2) : undefined, 35 | Inflow: r[PAID_IN].trim() ? Number(r[PAID_IN]).toFixed(2) : undefined, 36 | })), 37 | }, 38 | ]; 39 | }; 40 | 41 | export const revolutMatcher: MatcherFunction = async (file: File) => { 42 | const requiredKeys: string[] = ['Completed Date', 'Reference', 'Exchange Rate', 'Category']; 43 | 44 | const { data } = await parseCsv(file, { preview: 1 }); 45 | 46 | if (data.length === 0) { 47 | return false; 48 | } 49 | 50 | const csvColumnNames = data[0].map(r => r.trim()); 51 | return requiredKeys.every(key => csvColumnNames.includes(key)); 52 | }; 53 | 54 | export const revolut: ParserModule = { 55 | name: 'Revolut', 56 | country: 'international', 57 | fileExtension: 'csv', 58 | filenamePattern: /^Revolut-(.+)-Statement-(.+)\.csv$/, 59 | link: 'https://blog.revolut.com/new-feature-exportable-statements/', 60 | match: revolutMatcher, 61 | parse: revolutParser, 62 | }; 63 | 64 | const isValidDate = (date: Date) => !isNaN(date.getTime()); 65 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/mx/bbva-bancomer/bbva-bancomer.spec.ts: -------------------------------------------------------------------------------- 1 | import { generateYnabDate, bancomer, trimMetaData } from './bbva-bancomer'; 2 | import { YnabFile } from '../..'; 3 | import { encode, decode } from 'iconv-lite'; 4 | 5 | const content = encode( 6 | `Card number: ·9999 7 | TRANSACTION BREAKDOWN 8 | DATE DESCRIPTION OUTFLOW INFLOW BALANCE 9 | Titular *9999 10 | 13/04/2019 RESTAURANT "1,064.80" "11,509.22" 11 | 12/4/2019 GROCERIES 471.66 "10,444.42" 12 | Digital *8888 13 | 9/4/2019 AMAZON "1,022.00" "9,651.92" 14 | 15 | "BBVA Bancomer, S.A., Institución de Banca Múltiple, Grupo Financiero BBVA Bancomer." 16 | `, 17 | 'utf16le', 18 | ); 19 | 20 | const trimmedContent = `13/04/2019 RESTAURANT "1,064.80" "11,509.22" 21 | 12/4/2019 GROCERIES 471.66 "10,444.42" 22 | 9/4/2019 AMAZON "1,022.00" "9,651.92"`; 23 | 24 | const ynabResult: YnabFile[] = [ 25 | { 26 | data: [ 27 | { 28 | Date: '04/13/2019', 29 | Inflow: undefined, 30 | Memo: 'RESTAURANT', 31 | Outflow: 1064.8, 32 | }, 33 | { 34 | Date: '04/12/2019', 35 | Inflow: undefined, 36 | Memo: 'GROCERIES', 37 | Outflow: 471.66, 38 | }, 39 | { 40 | Date: '04/09/2019', 41 | Inflow: undefined, 42 | Memo: 'AMAZON', 43 | Outflow: 1022, 44 | }, 45 | ], 46 | }, 47 | ]; 48 | 49 | describe('BBVA Bancomer Parser Module', () => { 50 | describe('Matcher', () => { 51 | it('should match bancomer files by file name', async () => { 52 | const fileName = 'descarga.csv'; 53 | const result = !!fileName.match(bancomer.filenamePattern); 54 | expect(result).toBe(true); 55 | }); 56 | 57 | it('should not match other files by file name', async () => { 58 | const invalidFile = new File([], 'test.csv'); 59 | const result = await bancomer.match(invalidFile); 60 | expect(result).toBe(false); 61 | }); 62 | 63 | it('should match bancomer files by fields', async () => { 64 | const file = new File([content], 'test.csv'); 65 | const result = await bancomer.match(file); 66 | expect(result).toBe(true); 67 | }); 68 | 69 | it('should not match empty files', async () => { 70 | const file = new File([], 'test.csv'); 71 | const result = await bancomer.match(file); 72 | expect(result).toBe(false); 73 | }); 74 | }); 75 | 76 | describe('Parser', () => { 77 | it('should parse data correctly', async () => { 78 | const file = new File([content], 'test.csv'); 79 | const result = await bancomer.parse(file); 80 | expect(result).toEqual(ynabResult); 81 | }); 82 | }); 83 | 84 | describe('Date Converter', () => { 85 | it('should format an input date correctly', () => { 86 | expect(generateYnabDate('03/05/2018')).toEqual('05/03/2018'); 87 | }); 88 | 89 | it('should throw an error when the input date is incorrect', () => { 90 | expect(() => generateYnabDate('1.1.1')).toThrow('not a valid date'); 91 | }); 92 | }); 93 | 94 | describe('Metadata Trimmer', () => { 95 | it('should trim all metadata from a valid input string', () => { 96 | expect(trimMetaData(decode(content, 'utf16le'))).toEqual(trimmedContent); 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/mx/bbva-bancomer/bbva-bancomer.ts: -------------------------------------------------------------------------------- 1 | import 'mdn-polyfills/String.prototype.startsWith'; 2 | import { ParserFunction, MatcherFunction, ParserModule } from '../..'; 3 | import { parse } from '../../util/papaparse'; 4 | import { readEncodedFile } from '../../util/read-encoded-file'; 5 | 6 | // Bancomer Row: 7 | // DATE, DESCRIPTION, OUTFLOW, INFLOW, BALANCE 8 | 9 | /* 10 | * Bancomer files are encoded as UTF16-LE. Since jschardet doesn't seem 11 | * to recognize that charset most of the time, we need to force it. 12 | */ 13 | 14 | export const generateYnabDate = (input: string) => { 15 | const match = input.match(/(\d{1,2})\/(\d{1,2})\/(\d{4})/); 16 | 17 | if (!match) { 18 | throw new Error('The input is not a valid date. Expected format: DD/MM/YYYY'); 19 | } 20 | 21 | const [, day, month, year] = match; 22 | return [month.padStart(2, '0'), day.padStart(2, '0'), year].join('/'); 23 | }; 24 | 25 | export const parseNumber = (input: string) => Number(input.replace(',', '')); 26 | 27 | export const trimMetaData = (input: string) => { 28 | const lines = input.split('\n'); 29 | 30 | return lines 31 | .splice(3) 32 | .filter( 33 | l => 34 | l && l.trim() !== '' && !l.startsWith(' ') && !l.match(/^"BBVA (.+)"/), 35 | ) 36 | .join('\n'); 37 | }; 38 | 39 | export const bancomerParser: ParserFunction = async (file: File) => { 40 | const fileString = trimMetaData(await readEncodedFile(file, 'utf16le')); 41 | const { data } = await parse(fileString, { delimiter: '\t' }); 42 | 43 | return [ 44 | { 45 | data: (data as string[][]) 46 | .filter(r => r[0] && r[0].trim()) 47 | .map(r => ({ 48 | Date: generateYnabDate(r[0]), 49 | Memo: r[1], 50 | Outflow: r[2] ? parseNumber(r[2]) : undefined, 51 | Inflow: r[3] ? parseNumber(r[3]) : undefined, 52 | })), 53 | }, 54 | ]; 55 | }; 56 | 57 | export const bancomerMatcher: MatcherFunction = async (file: File) => { 58 | const rawFileString = await readEncodedFile(file, 'utf16le'); 59 | 60 | if (rawFileString.length === 0) { 61 | return false; 62 | } 63 | 64 | if ( 65 | rawFileString.startsWith('Card number: ') || 66 | rawFileString.startsWith('Número de Tarjeta: ') 67 | ) { 68 | return true; 69 | } 70 | 71 | // This might happen when the file encoding is wrong. 72 | if (rawFileString.indexOf('\n') === -1) { 73 | return false; 74 | } 75 | 76 | const headerRow = rawFileString.split('\n')[2].trim(); 77 | if ( 78 | headerRow === 'DATE\tDESCRIPTION\tOUTFLOW\tINFLOW\tBALANCE' || 79 | headerRow === 'FECHA\tDESCRIPCIÓN\tCARGO\tABONO\tSALDO' 80 | ) { 81 | return true; 82 | } 83 | 84 | return false; 85 | }; 86 | 87 | export const bancomer: ParserModule = { 88 | name: 'bancomer', 89 | country: 'mx', 90 | fileExtension: 'csv', 91 | filenamePattern: /^descarga\.[c|t]sv$/, 92 | link: 'https://www.bancomer.com', 93 | match: bancomerMatcher, 94 | parse: bancomerParser, 95 | }; 96 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/pl/bank-pocztowy/bank-pocztowy.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import iconv from 'iconv-lite'; 4 | 5 | import { YnabFile } from '../..'; 6 | import { bankPocztowy, FILE_ENCODING } from './bank-pocztowy'; 7 | 8 | const ynabResult: YnabFile[] = [ 9 | { 10 | data: [ 11 | { 12 | Date: '10/11/2019', 13 | Payee: undefined, 14 | Memo: 'OUTCOME 0232-333', 15 | Outflow: '1008.03', 16 | Inflow: undefined, 17 | }, 18 | { 19 | Date: '10/11/2019', 20 | Payee: undefined, 21 | Memo: 'INCOME', 22 | Outflow: undefined, 23 | Inflow: '1000.00', 24 | }, 25 | { 26 | Date: '10/09/2019', 27 | Payee: undefined, 28 | Memo: 'Opłata za kartę nr:9988 44xx xxxx 3333za okres 09.2019', 29 | Outflow: '5.00', 30 | Inflow: undefined, 31 | }, 32 | ], 33 | }, 34 | ]; 35 | 36 | describe('Bank Pocztowy Parser Module', () => { 37 | describe('Matcher', () => { 38 | it('should match bank pocztowy files by file name', async () => { 39 | const fileName = '1571076127593.csv'; 40 | const result = !!fileName.match(bankPocztowy.filenamePattern); 41 | expect(result).toBe(true); 42 | }); 43 | 44 | it('should not match other files by file name', async () => { 45 | const invalidFile = new File([], 'test.csv'); 46 | const result = await bankPocztowy.match(invalidFile); 47 | expect(result).toBe(false); 48 | }); 49 | 50 | it('should match bankPocztowy files by fields', async () => { 51 | const content = fs.readFileSync( 52 | path.resolve(__dirname, 'test-data', '1571076127593.csv'), 53 | ); 54 | const file = new File([iconv.decode(content, FILE_ENCODING)], 'test.csv'); 55 | const result = await bankPocztowy.match(file); 56 | expect(result).toBe(true); 57 | }); 58 | 59 | it('should not match empty files', async () => { 60 | const file = new File([], 'test.csv'); 61 | const result = await bankPocztowy.match(file); 62 | expect(result).toBe(false); 63 | }); 64 | }); 65 | 66 | describe('Parser', () => { 67 | it('should parse data correctly', async () => { 68 | const content = fs.readFileSync( 69 | path.resolve(__dirname, 'test-data', '1571076127593.csv'), 70 | ); 71 | const file = new File([content], '1571076127593.csv'); 72 | const result = await bankPocztowy.parse(file); 73 | 74 | expect(result).toEqual(ynabResult); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/pl/bank-pocztowy/bank-pocztowy.ts: -------------------------------------------------------------------------------- 1 | import { ParserFunction, MatcherFunction, ParserModule } from '../..'; 2 | import { readEncodedFile } from '../../util/read-encoded-file'; 3 | import { parse } from '../../util/papaparse'; 4 | 5 | type bankPocztowyRow = [ 6 | string, 7 | string, 8 | string, 9 | string, 10 | string, 11 | string, 12 | string, 13 | string, 14 | string, 15 | ]; 16 | 17 | export const FILE_ENCODING = 'windows1250'; 18 | const DATE_REGEXP = /\d{4}\s\d{2}\s\d{2}/; 19 | const ACCOUNT_NUMBER_REGEXP = /\d{2}1320\d{20}/; 20 | const AMOUNT_CLEANUP_REGEXP = /[-PLN\s]/g; 21 | const PAYEE_REGEXP = /\d{26},(\d{26}|(\d{1,}-){1,}\d),(.+?),-?\d{1,}\.\d{2},PLN/g; 22 | const NEW_LINE_REGEXP = /\|/g; 23 | const PARSER_SETTINGS = { 24 | header: false, 25 | delimiter: ',', 26 | quoteChar: '"', 27 | }; 28 | 29 | const fixInput = (input: string) => 30 | input 31 | .trim() 32 | .split(/\n/) 33 | .map(line => 34 | line.replace(PAYEE_REGEXP, (match, a, b, payee) => 35 | match.replace(payee, payee.replace(/,/g, '__')), 36 | ), 37 | ) 38 | .join('\n'); 39 | 40 | const bankPocztowyMatch: MatcherFunction = async (file: File) => { 41 | const fileString = await readEncodedFile(file, FILE_ENCODING); 42 | 43 | try { 44 | const { data } = await parse(fixInput(fileString), PARSER_SETTINGS); 45 | const [row] = data; 46 | 47 | if (data.length === 0) { 48 | return false; 49 | } 50 | 51 | if (row.length !== 9) { 52 | return false; 53 | } 54 | 55 | if ( 56 | row[0].match(DATE_REGEXP) && 57 | row[1].match(DATE_REGEXP) && 58 | row[2].match(ACCOUNT_NUMBER_REGEXP) 59 | ) { 60 | return true; 61 | } 62 | } catch (e) { 63 | return false; 64 | } 65 | 66 | return false; 67 | }; 68 | 69 | const bankPocztowyParser: ParserFunction = async (file: File) => { 70 | const fileString = await readEncodedFile(file, FILE_ENCODING); 71 | const { data } = await parse(fixInput(fileString), PARSER_SETTINGS); 72 | const result = data as bankPocztowyRow[]; 73 | 74 | return [ 75 | { 76 | data: result 77 | .filter(item => item.length === 9) 78 | .map(item => { 79 | const [YYYY, MM, DD] = item[0].split(' '); 80 | const isOutflow = item[5].startsWith('-'); 81 | const amount = item[5] 82 | .replace(AMOUNT_CLEANUP_REGEXP, '') 83 | .replace(',', '.'); 84 | 85 | return { 86 | Memo: item[7].replace(NEW_LINE_REGEXP, ''), 87 | Date: [MM, DD, YYYY].join('/'), 88 | Payee: undefined, 89 | Outflow: isOutflow ? amount : undefined, 90 | Inflow: !isOutflow ? amount : undefined, 91 | }; 92 | }), 93 | }, 94 | ]; 95 | }; 96 | 97 | export const bankPocztowy: ParserModule = { 98 | name: 'Bank Pocztowy', 99 | country: 'pl', 100 | fileExtension: 'csv', 101 | filenamePattern: /^\d{13}\.csv$/, 102 | link: 'https://www.pocztowy.pl', 103 | match: bankPocztowyMatch, 104 | parse: bankPocztowyParser, 105 | }; 106 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/pl/bank-pocztowy/test-data/1571076127593.csv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-parsers/src/pl/bank-pocztowy/test-data/1571076127593.csv -------------------------------------------------------------------------------- /packages/ynap-parsers/src/pl/mbank/mbank.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import iconv from 'iconv-lite'; 4 | 5 | import { YnabFile } from '../..'; 6 | import { mbank } from './mbank'; 7 | 8 | const ynabResult: YnabFile[] = [ 9 | { 10 | data: [ 11 | { 12 | Date: '10/09/2019', 13 | Payee: undefined, 14 | Memo: 'OUTCOME', 15 | Outflow: '30.00', 16 | Inflow: undefined, 17 | }, 18 | { 19 | Date: '09/25/2019', 20 | Payee: undefined, 21 | Memo: 'INCOME', 22 | Outflow: undefined, 23 | Inflow: '2000.00', 24 | }, 25 | ], 26 | }, 27 | ]; 28 | 29 | describe('mBank Parser Module', () => { 30 | describe('Matcher', () => { 31 | it('should match mBank files by file name', async () => { 32 | const fileName = 'operations_190710_191010_201910100004038185.csv'; 33 | const result = !!fileName.match(mbank.filenamePattern); 34 | expect(result).toBe(true); 35 | }); 36 | 37 | it('should not match other files by file name', async () => { 38 | const invalidFile = new File([], 'test.csv'); 39 | const result = await mbank.match(invalidFile); 40 | expect(result).toBe(false); 41 | }); 42 | 43 | it('should match mBank files by fields', async () => { 44 | const content = fs.readFileSync( 45 | path.resolve( 46 | __dirname, 47 | 'test-data', 48 | 'operations_190710_191010_201910100004038185.csv', 49 | ), 50 | ); 51 | const file = new File([iconv.decode(content, 'msee')], 'test.csv'); 52 | const result = await mbank.match(file); 53 | expect(result).toBe(true); 54 | }); 55 | 56 | it('should not match empty files', async () => { 57 | const file = new File([], 'test.csv'); 58 | const result = await mbank.match(file); 59 | expect(result).toBe(false); 60 | }); 61 | }); 62 | 63 | describe('Parser', () => { 64 | it('should parse data correctly', async () => { 65 | const content = fs.readFileSync( 66 | path.resolve( 67 | __dirname, 68 | 'test-data', 69 | 'operations_190710_191010_201910100004038185.csv', 70 | ), 71 | ); 72 | const file = new File([content], 'test.csv'); 73 | const result = await mbank.parse(file); 74 | 75 | expect(result).toEqual(ynabResult); 76 | }); 77 | 78 | it('should properly escape quote characters', async () => { 79 | const content = [ 80 | '#Data operacji;#Opis operacji;#Rachunek;#Kategoria;#Kwota;#Saldo po operacji;', 81 | '2019-09-25;INCOME;eKonto 0000 ... 1111;Wpływy - inne;2 000,00 PLN;2 867,35 PLN', 82 | '2019-09-22;"QUOTED" Name.;eKonto 0000 ... 1111;Wydatki - inne;-18,39 PLN;4 684,40 PLN', 83 | '2019-09-25;INCOME;eKonto 0000 ... 1111;Wpływy - inne;2 000,00 PLN;2 867,35 PLN', 84 | ].join('\r\n'); 85 | const file = new File([iconv.encode(content, 'msee')], 'test.csv'); 86 | const result = await mbank.parse(file); 87 | 88 | expect(result[0].data).toHaveLength(3); 89 | expect(result[0].data[1].Memo).toBe('"QUOTED" Name.'); 90 | }); 91 | 92 | it('should parse uncleared transactions', async () => { 93 | const content = [ 94 | '#Data operacji;#Opis operacji;#Rachunek;#Kategoria;#Kwota;#Saldo po operacji;', 95 | '2019-09-25;INCOME;eKonto 0000 ... 1111;Wpływy - inne;2 000,00 PLN;2 867,35 PLN', 96 | '2019-09-22;"QUOTED" Name.', 97 | ' transakcja nierozliczona ', 98 | ' ;eKonto 0000 ... 1111;Wydatki - inne;-18,39 PLN;4 684,40 PLN', 99 | '2019-09-25;INCOME;eKonto 0000 ... 1111;Wpływy - inne;2 000,00 PLN;2 867,35 PLN', 100 | ].join('\r\n'); 101 | const file = new File([iconv.encode(content, 'msee')], 'test.csv'); 102 | const result = await mbank.parse(file); 103 | 104 | expect(result[0].data).toHaveLength(3); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/pl/mbank/mbank.ts: -------------------------------------------------------------------------------- 1 | import { ParserFunction, MatcherFunction, ParserModule } from '../..'; 2 | import { readEncodedFile } from '../../util/read-encoded-file'; 3 | import { parse } from '../../util/papaparse'; 4 | 5 | interface mBankRow { 6 | '#Data operacji': string; 7 | '#Opis operacji': string; 8 | '#Rachunek': string; 9 | '#Kategoria': string; 10 | '#Kwota': string; 11 | '#Saldo po operacji': string; 12 | } 13 | 14 | const FILE_ENCODING = 'msee'; 15 | const AMOUNT_CLEANUP_REGEXP = /[-PLN\s]/g; 16 | const SHEET_CLEANUP_REGEXP = /\s{3,}/g; 17 | const REQUIRED_FIELDS = ['#Data operacji', '#Kwota', '#Opis operacji']; 18 | const PARSER_SETTINGS = { 19 | header: true, 20 | delimiter: ';', 21 | quoteChar: "'", 22 | }; 23 | 24 | const cleanup = (input: string) => input.replace(SHEET_CLEANUP_REGEXP, '').trim(); 25 | const trimMetaData = (input: string) => 26 | input.substr(input.indexOf('#Data operacji;')); 27 | 28 | export const mbankMatch: MatcherFunction = async (file: File) => { 29 | const fileString = await readEncodedFile(file, FILE_ENCODING); 30 | 31 | if (fileString.startsWith('mBank S.A.')) { 32 | return true; 33 | } 34 | 35 | try { 36 | const { data } = await parse(trimMetaData(fileString), PARSER_SETTINGS); 37 | 38 | if (data.length === 0) { 39 | return false; 40 | } 41 | 42 | const keys = Object.keys(data[0]); 43 | const missingKeys = REQUIRED_FIELDS.filter(k => !keys.includes(k)); 44 | 45 | if (missingKeys.length === 0) { 46 | return true; 47 | } 48 | } catch (e) { 49 | return false; 50 | } 51 | 52 | return false; 53 | }; 54 | 55 | const mbankParser: ParserFunction = async (file: File) => { 56 | const fileString = cleanup( 57 | trimMetaData(await readEncodedFile(file, FILE_ENCODING)), 58 | ); 59 | const { data } = await parse(fileString, PARSER_SETTINGS); 60 | const result = data as mBankRow[]; 61 | 62 | return [ 63 | { 64 | data: result 65 | .filter(item => 66 | REQUIRED_FIELDS.every(key => typeof item[key] !== 'undefined'), 67 | ) 68 | .map(item => { 69 | const [YYYY, MM, DD] = item['#Data operacji'].split('-'); 70 | const isOutflow = item['#Kwota'].startsWith('-'); 71 | const amount = item['#Kwota'] 72 | .replace(AMOUNT_CLEANUP_REGEXP, '') 73 | .replace(',', '.'); 74 | 75 | return { 76 | Memo: item['#Opis operacji'], 77 | Date: [MM, DD, YYYY].join('/'), 78 | Payee: undefined, 79 | Outflow: isOutflow ? amount : undefined, 80 | Inflow: !isOutflow ? amount : undefined, 81 | }; 82 | }), 83 | }, 84 | ]; 85 | }; 86 | 87 | export const mbank: ParserModule = { 88 | name: 'mBank', 89 | country: 'pl', 90 | fileExtension: 'csv', 91 | filenamePattern: /^operations(_\d{6,}){3}\.csv$/, 92 | link: 'https://www.mbank.pl/', 93 | match: mbankMatch, 94 | parse: mbankParser, 95 | }; 96 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/pl/mbank/test-data/operations_190710_191010_201910100004038185.csv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-parsers/src/pl/mbank/test-data/operations_190710_191010_201910100004038185.csv -------------------------------------------------------------------------------- /packages/ynap-parsers/src/se/seb-privat/seb.spec.ts: -------------------------------------------------------------------------------- 1 | import { generateYnabDate, seb } from './seb'; 2 | import { YnabFile } from '../..'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | 6 | const content = fs.readFileSync( 7 | path.join(__dirname, 'test-data/kontoutdrag.xlsx'), 8 | ); 9 | 10 | const ynabResult: YnabFile[] = [ 11 | { 12 | data: [ 13 | { 14 | "Category": undefined, 15 | 'Date': '03/30/2020', 16 | 'Inflow': 875.63, 17 | 'Memo': 'VOLVOCARD', 18 | 'Outflow': undefined, 19 | }, 20 | { 21 | 'Category': undefined, 22 | 'Date': '03/29/2020', 23 | 'Inflow': undefined, 24 | 'Memo': 'BLOCKET AB', 25 | 'Outflow': 125, 26 | } 27 | ], 28 | }, 29 | ]; 30 | 31 | describe('SEB Bank Parser Module', () => { 32 | describe('Matcher', () => { 33 | it('should match SEB Bank files by file name', async () => { 34 | const fileName = 'kontoutdrag.xlsx'; 35 | const result = !!fileName.match(seb.filenamePattern); 36 | expect(result).toBe(true); 37 | }); 38 | 39 | it('should not match other files by file name', async () => { 40 | const invalidFile = new File([], 'test.xlsx'); 41 | const result = await seb.match(invalidFile); 42 | expect(result).toBe(false); 43 | }); 44 | 45 | it('should match SEB Bank files by fields', async () => { 46 | const file = new File([content], 'test.xlsx'); 47 | const result = await seb.match(file); 48 | expect(result).toBe(true); 49 | }); 50 | }); 51 | 52 | describe('Parser', () => { 53 | it('should parse data correctly', async () => { 54 | const file = new File([content], 'test.xlsx'); 55 | const result = await seb.parse(file); 56 | expect(result).toEqual(ynabResult); 57 | }); 58 | }); 59 | 60 | describe('Date Converter', () => { 61 | it('should format an input date correctly', () => { 62 | expect(generateYnabDate('2020-30-03')).toEqual('30/03/2020'); 63 | }); 64 | 65 | it('should throw an error when the input date is incorrect', () => { 66 | expect(() => generateYnabDate('1.1.1')).toThrow('not a valid date'); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/se/seb-privat/seb.ts: -------------------------------------------------------------------------------- 1 | import 'mdn-polyfills/String.prototype.startsWith'; 2 | import { ParserFunction, MatcherFunction, ParserModule, YnabRow } from '../..'; 3 | import { CellObject } from 'xlsx/types'; 4 | import { readToBuffer } from '../../util/read-to-buffer'; 5 | 6 | export const generateYnabDate = (input: string) => { 7 | const match = input.match(/(\d{4})-(\d{2})-(\d{2})/); 8 | 9 | if (!match) { 10 | throw new Error( 11 | 'The input is not a valid date. Expected format: YYYY-MM-DD, got ' + input, 12 | ); 13 | } 14 | 15 | const [, year, month, day] = match; 16 | return [month.padStart(2, '0'), day.padStart(2, '0'), year].join('/'); 17 | }; 18 | 19 | export const parseNumber = (input: string) => Number(input.replace(',', '')); // , is for thousands separator 20 | 21 | export const sebPrivatParser: ParserFunction = async (file: File) => { 22 | const xlsx = await import('xlsx'); 23 | const workbook = xlsx.read(await readToBuffer(file), { 24 | type: 'buffer', 25 | }); 26 | 27 | const sheet = workbook.Sheets[workbook.SheetNames[0]]; 28 | const rows: YnabRow[] = []; 29 | 30 | let rowNum = 9; 31 | while (true) { 32 | let dateCol: CellObject | undefined = sheet[`A${rowNum}`]; 33 | if (!dateCol || dateCol.t === 'e' || String(dateCol.v).trim() === '') { 34 | break; 35 | } 36 | 37 | rows.push({ 38 | Category: undefined, 39 | Date: generateYnabDate(String(sheet[`B${rowNum}`].v)), 40 | Memo: String(sheet[`D${rowNum}`].v) 41 | .split('\r')[0] 42 | .trim(), 43 | Inflow: sheet[`E${rowNum}`].v > 0 ? sheet[`E${rowNum}`].v : undefined, 44 | Outflow: sheet[`E${rowNum}`].v < 0 ? -sheet[`E${rowNum}`].v : undefined, 45 | }); 46 | 47 | rowNum++; 48 | } 49 | 50 | return [ 51 | { 52 | data: rows, 53 | }, 54 | ]; 55 | }; 56 | 57 | export const sebMatcher: MatcherFunction = async (file: File) => { 58 | try { 59 | const xlsx = await import('xlsx'); 60 | const workbook = xlsx.read(await readToBuffer(file), { 61 | type: 'buffer', 62 | }); 63 | 64 | const cell: CellObject = workbook.Sheets[workbook.SheetNames[0]]['A1']; 65 | return cell.v === 'Export från internetbanken för privatpersoner'; 66 | } catch (e) { 67 | return false; 68 | } 69 | }; 70 | 71 | export const seb: ParserModule = { 72 | name: 'SEB Bank', 73 | country: 'se', 74 | fileExtension: 'xlsx', 75 | filenamePattern: /kontoutdrag.xlsx/, 76 | link: 'https://www.seb.se', 77 | match: sebMatcher, 78 | parse: sebPrivatParser, 79 | }; 80 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/se/seb-privat/test-data/kontoutdrag.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-parsers/src/se/seb-privat/test-data/kontoutdrag.xlsx -------------------------------------------------------------------------------- /packages/ynap-parsers/src/se/sparbanken-tanum/2018/sparbanken-tanum.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { generateYnabDate, sparbankenTanum } from './sparbanken-tanum'; 4 | import { YnabRow, YnabFile } from '../../..'; 5 | 6 | const content = fs.readFileSync(path.join(__dirname, 'test-data', 'export.csv')); 7 | 8 | const ynabResult: YnabFile[] = [ 9 | { 10 | data: [ 11 | { 12 | Date: '07/19/2019', 13 | Inflow: undefined, 14 | Outflow: '1.00', 15 | Payee: 'Övf via internet', 16 | }, 17 | { 18 | Date: '07/19/2019', 19 | Inflow: undefined, 20 | Outflow: '10.00', 21 | Payee: 'ITUNES.COM/BILL', 22 | }, 23 | ], 24 | }, 25 | ]; 26 | 27 | describe('Sparbanken Tanum Parser Module', () => { 28 | describe('Matcher', () => { 29 | it('should match Sparbanken Tanum files by fields', async () => { 30 | const file = new File([content], 'test.csv'); 31 | const result = await sparbankenTanum.match(file); 32 | expect(result).toBe(true); 33 | }); 34 | 35 | it('should not match empty files', async () => { 36 | const file = new File([], 'test.csv'); 37 | const result = await sparbankenTanum.match(file); 38 | expect(result).toBe(false); 39 | }); 40 | }); 41 | 42 | describe('Parser', () => { 43 | it('should parse data correctly', async () => { 44 | const file = new File([content], 'test.csv'); 45 | const result = await sparbankenTanum.parse(file); 46 | expect(result).toEqual(ynabResult); 47 | }); 48 | }); 49 | 50 | describe('Date Converter', () => { 51 | it('should convert dates correctly', () => { 52 | expect(generateYnabDate('2018-09-01')).toEqual('09/01/2018'); 53 | }); 54 | 55 | it('should throw an error when the input date is incorrect', () => { 56 | expect(() => generateYnabDate('1.1.1')).toThrow('not a valid date'); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/se/sparbanken-tanum/2018/sparbanken-tanum.ts: -------------------------------------------------------------------------------- 1 | import 'mdn-polyfills/String.prototype.startsWith'; 2 | import { ParserFunction, MatcherFunction, ParserModule } from '../../..'; 3 | import { parse } from '../../../util/papaparse'; 4 | 5 | /* 6 | * Row format: 7 | * Payee; date; skip; Inflow and Outflow; skip 8 | */ 9 | 10 | export const generateYnabDate = (input: string) => { 11 | const match = input.match(/(\d{4})-(\d{2})-(\d{2})/); 12 | 13 | if (!match) { 14 | throw new Error('The input is not a valid date. Expected format: YYYY-MM-DD'); 15 | } 16 | 17 | const [, year, month, day] = match; 18 | return [month.padStart(2, '0'), day.padStart(2, '0'), year].join('/'); 19 | }; 20 | 21 | export const toNumber = (input: string) => 22 | Number(input.replace(',', '.').replace(' ', '')); 23 | 24 | export const sparbankenTanumParser: ParserFunction = async (file: File) => { 25 | const { data } = await parse(file, { delimiter: ';' }); 26 | 27 | return [ 28 | { 29 | data: data 30 | .filter(r => r[0] && r[2] !== '-') 31 | .map(r => ({ 32 | Date: generateYnabDate(r[1]), 33 | Payee: String(r[0]).trim(), 34 | Outflow: toNumber(r[3]) < 0 ? (-toNumber(r[3])).toFixed(2) : undefined, 35 | Inflow: toNumber(r[3]) > 0 ? toNumber(r[3]).toFixed(2) : undefined, 36 | })), 37 | }, 38 | ]; 39 | }; 40 | 41 | export const sparbankenTanumMatcher: MatcherFunction = async (file: File) => { 42 | const { data } = await parse(file, { delimiter: ';' }); 43 | 44 | // Check if the file contains any data 45 | if (data.length === 0) { 46 | return false; 47 | } 48 | 49 | // Check if the second field is a date 50 | if (!String(data[0][1]).match(/\d{4}-\d{2}-\d{2}/)) { 51 | return false; 52 | } 53 | 54 | // Check if the fourth field is a valid number 55 | if (!String(data[0][3]).match(/-?[\d ]+,\d{2}/)) { 56 | return false; 57 | } 58 | 59 | return true; 60 | }; 61 | 62 | export const sparbankenTanum: ParserModule = { 63 | name: 'Sparbanken Tanum', 64 | country: 'se', 65 | fileExtension: 'csv', 66 | filenamePattern: /^export\.csv$/, 67 | link: 'https://www.sparbankentanum.se/', 68 | match: sparbankenTanumMatcher, 69 | parse: sparbankenTanumParser, 70 | }; 71 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/se/sparbanken-tanum/2018/test-data/export.csv: -------------------------------------------------------------------------------- 1 | SKYDDAT BELOPP;2019-07-19;-;-196,00;- 2 | SKYDDAT BELOPP;2019-07-19;-;-5,00;- 3 | Övf via internet ;2019-07-19;2019-07-22;-1,00;22 102,16 4 | ITUNES.COM/BILL;2019-07-19;2019-07-19;-10,00;22 103,16 -------------------------------------------------------------------------------- /packages/ynap-parsers/src/se/sparbanken-tanum/2019/sparbanken-tanum.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { generateYnabDate, sparbankenTanum } from './sparbanken-tanum'; 4 | import { YnabFile } from '../../..'; 5 | 6 | const data: YnabFile[] = [ 7 | { 8 | accountName: 'Privatkonto-0036110559', 9 | data: [ 10 | { 11 | Date: '10/12/2019', 12 | Payee: 'ANTIKVARIAT NORD', 13 | Category: 'Swish', 14 | Outflow: '120.00', 15 | }, 16 | { 17 | Date: '10/12/2019', 18 | Payee: 'Klaraspar', 19 | Category: 'Överföring', 20 | Outflow: '11.00', 21 | }, 22 | { 23 | Date: '10/11/2019', 24 | Payee: 'NOVARA MEDIA DON', 25 | Category: 'Kortköp/uttag', 26 | Outflow: '61.95', 27 | }, 28 | { 29 | Date: '10/10/2019', 30 | Payee: 'HEMGLASS GOTEBOR', 31 | Category: 'Kortköp/uttag', 32 | Outflow: '119.00', 33 | }, 34 | { 35 | Date: '10/10/2019', 36 | Payee: 'THE RED LION', 37 | Category: 'Kortköp/uttag', 38 | Outflow: '600.00', 39 | }, 40 | { 41 | Date: '10/10/2019', 42 | Payee: 'SNABB SKO OCH NY', 43 | Category: 'Kortköp/uttag', 44 | Outflow: '160.00', 45 | }, 46 | { 47 | Date: '10/10/2019', 48 | Payee: 'PARKERINGSBOLAGE', 49 | Category: 'Kortköp/uttag', 50 | Outflow: '319.00', 51 | }, 52 | { 53 | Date: '10/10/2019', 54 | Payee: 'PARKERINGSBOLAGE', 55 | Category: 'Kortköp/uttag', 56 | Inflow: '290.00', 57 | }, 58 | { 59 | Date: '10/09/2019', 60 | Payee: 'TIER SE 10-54856', 61 | Category: 'Kortköp/uttag', 62 | Outflow: '20.00', 63 | }, 64 | { 65 | Date: '10/09/2019', 66 | Payee: 'GRO', 67 | Category: 'Kortköp/uttag', 68 | Outflow: '600.00', 69 | }, 70 | { 71 | Date: '10/09/2019', 72 | Payee: '+46739803283', 73 | Category: 'Swish skickad', 74 | Outflow: '79.00', 75 | }, 76 | { 77 | Date: '10/09/2019', 78 | Payee: '+46735127836', 79 | Category: 'Swish skickad', 80 | Outflow: '600.00', 81 | }, 82 | { 83 | Date: '10/09/2019', 84 | Payee: '+46730357319', 85 | Category: 'Swish skickad', 86 | Outflow: '300.00', 87 | }, 88 | { 89 | Date: '10/08/2019', 90 | Payee: 'NEW DELI', 91 | Category: 'Kortköp/uttag', 92 | Outflow: '79.00', 93 | }, 94 | { 95 | Date: '10/07/2019', 96 | Payee: 'ZENIT CAF', 97 | Category: 'Kortköp/uttag', 98 | Outflow: '25.00', 99 | }, 100 | { 101 | Date: '10/07/2019', 102 | Payee: 'LINDEX/153', 103 | Category: 'Kortköp/uttag', 104 | Outflow: '198.00', 105 | }, 106 | { 107 | Date: '10/07/2019', 108 | Payee: 'PANDURO HOBBY', 109 | Category: 'Kortköp/uttag', 110 | Outflow: '322.20', 111 | }, 112 | { 113 | Date: '10/07/2019', 114 | Payee: 'SJ AB', 115 | Category: 'Swish', 116 | Outflow: '295.00', 117 | }, 118 | { 119 | Date: '10/07/2019', 120 | Payee: 'SJ AB', 121 | Category: 'Swish', 122 | Outflow: '2050.00', 123 | }, 124 | { 125 | Date: '10/06/2019', 126 | Payee: 'MARKUS SWERLANDE', 127 | Category: 'Swish', 128 | Outflow: '20.00', 129 | }, 130 | { 131 | Date: '10/05/2019', 132 | Payee: 'Tre Indier', 133 | Category: 'Kortköp/uttag', 134 | Outflow: '400.00', 135 | }, 136 | { 137 | Date: '10/05/2019', 138 | Payee: 'KOCKJOHAN HANDEL', 139 | Category: 'Kortköp/uttag', 140 | Outflow: '99.00', 141 | }, 142 | { 143 | Date: '10/05/2019', 144 | Payee: 'TIER SE 10-23247', 145 | Category: 'Kortköp/uttag', 146 | Outflow: '16.00', 147 | }, 148 | { 149 | Date: '10/05/2019', 150 | Payee: 'HEMMA HOS', 151 | Category: 'Kortköp/uttag', 152 | Outflow: '230.00', 153 | }, 154 | { 155 | Date: '10/05/2019', 156 | Payee: '+46733691750', 157 | Category: 'Swish skickad', 158 | Outflow: '300.00', 159 | }, 160 | { 161 | Date: '10/05/2019', 162 | Payee: 'Klaraspar', 163 | Category: 'Överföring', 164 | Outflow: '11.00', 165 | }, 166 | { 167 | Date: '10/02/2019', 168 | Payee: 'HSO*Fußkomplizen', 169 | Category: 'Kortköp/uttag', 170 | Outflow: '1275.71', 171 | }, 172 | { 173 | Date: '10/01/2019', 174 | Payee: 'MAX GAMLA ULLEVI', 175 | Category: 'Kortköp/uttag', 176 | Outflow: '118.00', 177 | }, 178 | ], 179 | }, 180 | ]; 181 | 182 | const content = fs.readFileSync( 183 | path.join(__dirname, 'test-data', 'Transaktioner_2019-10-12_14-57-29.csv'), 184 | ); 185 | 186 | describe('Sparbanken Tanum Parser Module (2019)', () => { 187 | describe('Matcher', () => { 188 | it('should match Sparbanken Tanum files by fields', async () => { 189 | const file = new File([content], 'test.csv'); 190 | const result = await sparbankenTanum.match(file); 191 | expect(result).toBe(true); 192 | }); 193 | 194 | it('should not match empty files', async () => { 195 | const file = new File([], 'test.csv'); 196 | const result = await sparbankenTanum.match(file); 197 | expect(result).toBe(false); 198 | }); 199 | }); 200 | 201 | describe('Parser', () => { 202 | it('should parse data correctly', async () => { 203 | const file = new File([content], 'test.csv'); 204 | const result = await sparbankenTanum.parse(file); 205 | expect(result).toEqual(data); 206 | }); 207 | }); 208 | 209 | describe('Date Converter', () => { 210 | it('should convert dates correctly', () => { 211 | expect(generateYnabDate('2018-09-01')).toEqual('09/01/2018'); 212 | }); 213 | 214 | it('should throw an error when the input date is incorrect', () => { 215 | expect(() => generateYnabDate('1.1.1')).toThrow('not a valid date'); 216 | }); 217 | }); 218 | }); 219 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/se/sparbanken-tanum/2019/sparbanken-tanum.ts: -------------------------------------------------------------------------------- 1 | import 'mdn-polyfills/String.prototype.startsWith'; 2 | import { ParserFunction, MatcherFunction, ParserModule, YnabRow } from '../../..'; 3 | import { parse } from '../../../util/papaparse'; 4 | import { readEncodedFile } from '../../../util/read-encoded-file'; 5 | 6 | export interface Row { 7 | Radnummer: string; 8 | Clearingnummer: string; 9 | Kontonummer: string; 10 | Produkt: string; 11 | Valuta: string; 12 | Bokföringsdag: string; 13 | Transaktionsdag: string; 14 | Valutadag: string; 15 | Referens: string; 16 | Beskrivning: string; 17 | Belopp: string; 18 | 'Bokfört saldo': string; 19 | } 20 | 21 | export const generateYnabDate = (input: string) => { 22 | const match = input.match(/(\d{4})-(\d{2})-(\d{2})/); 23 | 24 | if (!match) { 25 | throw new Error('The input is not a valid date. Expected format: YYYY-MM-DD'); 26 | } 27 | 28 | const [, year, month, day] = match; 29 | return [month.padStart(2, '0'), day.padStart(2, '0'), year].join('/'); 30 | }; 31 | 32 | export const trimMeta = (input: string) => input.substr(input.indexOf('Radnummer')); 33 | 34 | export const sparbankenTanumParser: ParserFunction = async (file: File) => { 35 | const { data } = await parse(trimMeta(await readEncodedFile(file)), { 36 | header: true, 37 | }); 38 | 39 | const groupedData = (data as Row[]) 40 | .filter(r => r.Radnummer && r.Belopp) 41 | .reduce( 42 | (acc, cur) => { 43 | const amount = Number(cur.Belopp); 44 | const data = { 45 | Date: generateYnabDate(cur.Transaktionsdag), 46 | Payee: cur.Referens, 47 | Category: cur.Beskrivning, 48 | Outflow: amount < 0 ? Math.abs(amount).toFixed(2) : undefined, 49 | Inflow: amount > 0 ? amount.toFixed(2) : undefined, 50 | }; 51 | 52 | const key = [cur.Produkt, cur.Kontonummer].filter(Boolean).join('-'); 53 | 54 | if (Object.keys(acc).includes(key)) { 55 | acc[key].push(data); 56 | } else { 57 | acc[key] = [data]; 58 | } 59 | 60 | return acc; 61 | }, 62 | {} as Record, 63 | ); 64 | 65 | return Object.keys(groupedData).map(key => ({ 66 | accountName: key, 67 | data: groupedData[key], 68 | })); 69 | }; 70 | 71 | export const sparbankenTanumMatcher: MatcherFunction = async (file: File) => { 72 | const { data } = await parse(trimMeta(await readEncodedFile(file)), { 73 | header: true, 74 | }); 75 | 76 | // Check if the file contains any data 77 | if (data.length === 0) { 78 | return false; 79 | } 80 | 81 | // Check if the first date field is a date 82 | if (!String((data[0] as Row).Bokföringsdag).match(/\d{4}-\d{2}-\d{2}/)) { 83 | return false; 84 | } 85 | 86 | // Check if the fourth field is a valid number 87 | if (!String((data[0] as Row).Belopp).match(/-?\d+\.\d{2}/)) { 88 | return false; 89 | } 90 | 91 | return true; 92 | }; 93 | 94 | export const sparbankenTanum: ParserModule = { 95 | name: 'Sparbanken Tanum (2019)', 96 | country: 'se', 97 | fileExtension: 'csv', 98 | filenamePattern: /^Transaktioner_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.csv$/, 99 | link: 'https://www.sparbankentanum.se/', 100 | match: sparbankenTanumMatcher, 101 | parse: sparbankenTanumParser, 102 | }; 103 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/se/sparbanken-tanum/2019/test-data/Transaktioner_2019-10-12_14-57-29.csv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-parsers/src/se/sparbanken-tanum/2019/test-data/Transaktioner_2019-10-12_14-57-29.csv -------------------------------------------------------------------------------- /packages/ynap-parsers/src/uk/aqua/aqua.spec.ts: -------------------------------------------------------------------------------- 1 | import { generateYnabDate, aqua } from './aqua'; 2 | import { YnabFile } from '../..'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | 6 | const content = fs.readFileSync(path.join(__dirname, 'test-data/transactions.csv')); 7 | 8 | const ynabResult: YnabFile[] = [ 9 | { 10 | data: [ 11 | { 12 | Date: '06/06/2019', 13 | Outflow: '19.87', 14 | Memo: 'Trainline London GBR', 15 | }, 16 | { 17 | Date: '05/27/2019', 18 | Memo: 'PAYMENT RECEIVED - THANK YOU', 19 | Inflow: '59.64', 20 | }, 21 | ], 22 | }, 23 | ]; 24 | 25 | describe('Aqua Parser Module', () => { 26 | describe('Matcher', () => { 27 | it('should match Aqua files by fields', async () => { 28 | const file = new File([content], 'test.csv'); 29 | const result = await aqua.match(file); 30 | expect(result).toBe(true); 31 | }); 32 | 33 | it('should not match empty files', async () => { 34 | const file = new File([], 'test.csv'); 35 | const result = await aqua.match(file); 36 | expect(result).toBe(false); 37 | }); 38 | }); 39 | 40 | describe('Parser', () => { 41 | it('should parse data correctly', async () => { 42 | const file = new File([content], 'test.csv'); 43 | const result = await aqua.parse(file); 44 | expect(result).toEqual(ynabResult); 45 | }); 46 | }); 47 | 48 | describe('Date Converter', () => { 49 | it('should convert dates correctly', () => { 50 | expect(generateYnabDate('01/09/2018')).toEqual('09/01/2018'); 51 | }); 52 | 53 | it('should throw an error when the input date is incorrect', () => { 54 | expect(() => generateYnabDate('1.1.1')).toThrow('not a valid date'); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/uk/aqua/aqua.ts: -------------------------------------------------------------------------------- 1 | import { ParserFunction, MatcherFunction, ParserModule, YnabRow } from '../..'; 2 | import { parse } from '../../util/papaparse'; 3 | 4 | export const generateYnabDate = (input: string) => { 5 | const match = input.match(/(\d{2})\/(\d{2})\/(\d{4})/); 6 | 7 | if (!match) { 8 | throw new Error( 9 | 'The input is not a valid date. Expected format: DD/MM/YYYY, got ' + input, 10 | ); 11 | } 12 | 13 | const [, day, month, year] = match; 14 | return [month, day, year].join('/'); 15 | }; 16 | 17 | export const aquaParser: ParserFunction = async (file: File) => { 18 | const { data } = await parse(file, { header: false }); 19 | 20 | const rows = (data as string[][]) 21 | .slice(1) 22 | .filter(r => r.length >= 3) 23 | .filter(r => r[0] !== 'Pending') 24 | .map( 25 | cur => 26 | ({ 27 | Date: generateYnabDate(cur[0]), 28 | Memo: cur[1].trim().replace(/\s\s+/g, ' '), 29 | Inflow: Number(cur[2]) < 0 ? (-Number(cur[2])).toFixed(2) : undefined, 30 | Outflow: Number(cur[2]) > 0 ? Number(cur[2]).toFixed(2) : undefined, 31 | } as YnabRow), 32 | ); 33 | 34 | return [ 35 | { 36 | data: rows, 37 | }, 38 | ]; 39 | }; 40 | 41 | export const aquaMatcher: MatcherFunction = async (file: File) => { 42 | const { data } = await parse(file, { header: false }); 43 | 44 | const requiredKeys = ['Date', 'Description']; 45 | 46 | if (data.length === 0) { 47 | return false; 48 | } 49 | 50 | const keys = data[0]; 51 | const missingKeys = requiredKeys.filter(k => !keys.includes(k)); 52 | 53 | if (missingKeys.length === 0) { 54 | return true; 55 | } 56 | 57 | return false; 58 | }; 59 | 60 | export const aqua: ParserModule = { 61 | name: 'Aqua', 62 | country: 'uk', 63 | fileExtension: 'csv', 64 | filenamePattern: /^transactions\.csv$/, 65 | link: 'https://www.aquacard.co.uk/', 66 | match: aquaMatcher, 67 | parse: aquaParser, 68 | }; 69 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/uk/aqua/test-data/transactions.csv: -------------------------------------------------------------------------------- 1 | Date,Description,Amount(GBP) 2 | Pending,OLDGATE GREAT BRITAIGBR,6.6 3 | 06/06/2019,Trainline London GBR,19.87 4 | 27/05/2019, PAYMENT RECEIVED - THANK YOU,-59.64 -------------------------------------------------------------------------------- /packages/ynap-parsers/src/uk/marcus/marcus.spec.ts: -------------------------------------------------------------------------------- 1 | import { generateYnabDate, marcus } from './marcus'; 2 | import { YnabFile } from '../..'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | 6 | const content = fs.readFileSync( 7 | path.join( 8 | __dirname, 9 | 'test-data/Transactions [Account Number] 2019-06-12 13_40.csv', 10 | ), 11 | ); 12 | 13 | const ynabResult: YnabFile[] = [ 14 | { 15 | accountName: 'Online Savings Account', 16 | data: [ 17 | { 18 | Date: '06/01/2019', 19 | Memo: 'Interest applied', 20 | Inflow: '1.41', 21 | Outflow: undefined, 22 | }, 23 | { 24 | Date: '05/24/2019', 25 | Memo: 'Transfer from REDACTED', 26 | Inflow: '588.47', 27 | Outflow: undefined, 28 | }, 29 | ], 30 | }, 31 | { 32 | accountName: 'Online Savings Account 2', 33 | data: [ 34 | { 35 | Date: '05/05/2019', 36 | Memo: 'Transfer from REDACTED', 37 | Inflow: '475.80', 38 | Outflow: undefined, 39 | }, 40 | { 41 | Date: '05/01/2019', 42 | Memo: 'Transfer from REDACTED', 43 | Inflow: '500.00', 44 | Outflow: undefined, 45 | }, 46 | ], 47 | }, 48 | ]; 49 | 50 | describe('Marcus Parser Module', () => { 51 | describe('Matcher', () => { 52 | it('should match Marcus files by name', async () => { 53 | const fileName = 'Transactions [Account Number] 2019-06-12 13_40.csv'; 54 | const result = !!fileName.match(marcus.filenamePattern); 55 | expect(result).toBe(true); 56 | }); 57 | 58 | it('should match Marcus files by fields', async () => { 59 | const file = new File([content], 'test.csv'); 60 | const result = await marcus.match(file); 61 | expect(result).toBe(true); 62 | }); 63 | 64 | it('should not match empty files', async () => { 65 | const file = new File([], 'test.csv'); 66 | const result = await marcus.match(file); 67 | expect(result).toBe(false); 68 | }); 69 | }); 70 | 71 | describe('Parser', () => { 72 | it('should parse data correctly', async () => { 73 | const file = new File([content], 'test.csv'); 74 | const result = await marcus.parse(file); 75 | expect(result).toEqual(ynabResult); 76 | }); 77 | }); 78 | 79 | describe('Date Converter', () => { 80 | it('should convert dates correctly', () => { 81 | expect(generateYnabDate('20180901')).toEqual('09/01/2018'); 82 | }); 83 | 84 | it('should throw an error when the input date is incorrect', () => { 85 | expect(() => generateYnabDate('1.1.1')).toThrow('not a valid date'); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/uk/marcus/marcus.ts: -------------------------------------------------------------------------------- 1 | import 'mdn-polyfills/String.prototype.startsWith'; 2 | import { ParserFunction, MatcherFunction, ParserModule, YnabRow } from '../..'; 3 | import { parse } from '../../util/papaparse'; 4 | 5 | export interface MarcusRow { 6 | TransactionDate: string; 7 | Description: string; 8 | Value: string; 9 | AccountBalance: string; 10 | AccountName: string; 11 | AccountNumber: string; 12 | } 13 | 14 | export const generateYnabDate = (input: string) => { 15 | const match = input.match(/(\d{4})(\d{2})(\d{2})/); 16 | 17 | if (!match) { 18 | throw new Error('The input is not a valid date. Expected format: YYYYMMDD'); 19 | } 20 | 21 | const [, year, month, day] = match; 22 | return [month.padStart(2, '0'), day.padStart(2, '0'), year].join('/'); 23 | }; 24 | 25 | export const marcusParser: ParserFunction = async (file: File) => { 26 | const { data } = await parse(file, { header: true }); 27 | 28 | const groupedData = (data as MarcusRow[]) 29 | .filter(r => r.TransactionDate && r.Value) 30 | .reduce( 31 | (acc, cur) => { 32 | const row = { 33 | Date: generateYnabDate(cur.TransactionDate), 34 | Memo: cur.Description, 35 | Outflow: 36 | Number(cur.Value) < 0 ? (-Number(cur.Value)).toFixed(2) : undefined, 37 | Inflow: Number(cur.Value) > 0 ? Number(cur.Value).toFixed(2) : undefined, 38 | }; 39 | 40 | const key = cur.AccountName || 'no-account'; 41 | 42 | if (Object.keys(acc).includes(key)) { 43 | acc[key].push(row); 44 | } else { 45 | acc[key] = [row]; 46 | } 47 | 48 | return acc; 49 | }, 50 | {} as { [k: string]: YnabRow[] }, 51 | ); 52 | 53 | return Object.keys(groupedData).map(key => ({ 54 | accountName: key, 55 | data: groupedData[key], 56 | })); 57 | }; 58 | 59 | export const marcusMatcher: MatcherFunction = async (file: File) => { 60 | const requiredKeys: (keyof MarcusRow)[] = [ 61 | 'TransactionDate', 62 | 'Description', 63 | 'Value', 64 | 'AccountBalance', 65 | 'AccountName', 66 | 'AccountNumber', 67 | ]; 68 | 69 | const { data } = await parse(file, { header: true }); 70 | 71 | if (data.length === 0) { 72 | return false; 73 | } 74 | 75 | const keys = Object.keys(data[0]); 76 | const missingKeys = requiredKeys.filter(k => !keys.includes(k)); 77 | 78 | if (missingKeys.length === 0) { 79 | return true; 80 | } 81 | 82 | return false; 83 | }; 84 | 85 | export const marcus: ParserModule = { 86 | name: 'Marcus', 87 | country: 'uk', 88 | fileExtension: 'csv', 89 | filenamePattern: /^Transactions (.+) (\d{4})-(\d{2})-(\d{2}) (\d{2})_(\d{2})\.csv$/, 90 | link: 'https://www.marcus.co.uk/uk/en', 91 | match: marcusMatcher, 92 | parse: marcusParser, 93 | }; 94 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/uk/marcus/test-data/Transactions [Account Number] 2019-06-12 13_40.csv: -------------------------------------------------------------------------------- 1 | "TransactionDate","Description","Value","AccountBalance","AccountName","AccountNumber" 2 | "20190601","Interest applied","1.41","1565.68","Online Savings Account","REDACTED" 3 | "20190524","Transfer from REDACTED","588.47","1564.27","Online Savings Account","REDACTED" 4 | "20190505","Transfer from REDACTED","475.8","975.8","Online Savings Account 2","REDACTED" 5 | "20190501","Transfer from REDACTED","500.0","500.0","Online Savings Account 2","REDACTED" 6 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/util/jschardet.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'jschardet' { 2 | interface Options { 3 | minimumThreshold: number; 4 | } 5 | 6 | interface Result { 7 | encoding: string | null; 8 | confidence: number; 9 | } 10 | 11 | export const detect: (str: string, options?: Options) => Result; 12 | } 13 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/util/papaparse.ts: -------------------------------------------------------------------------------- 1 | import { parse as papaParse, ParseConfig, ParseResult } from 'papaparse'; 2 | 3 | export const parse = ( 4 | file: File | string, 5 | config?: ParseConfig, 6 | ): Promise => 7 | new Promise((complete, error) => { 8 | papaParse(file as any, { 9 | ...config, 10 | complete, 11 | error, 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/util/read-encoded-file.ts: -------------------------------------------------------------------------------- 1 | import chardet from 'jschardet'; 2 | import { decode } from 'iconv-lite'; 3 | 4 | export const readEncodedFile = (file: File, charset?: string): Promise => { 5 | return new Promise((res, rej) => { 6 | const reader = new FileReader(); 7 | reader.addEventListener('load', () => { 8 | if (reader.result === null) { 9 | rej('Result is null.'); 10 | } 11 | 12 | const result = reader.result! as string; 13 | 14 | if (result.length === 0) { 15 | return res(''); 16 | } 17 | 18 | if (!charset) { 19 | const detectedCharset = chardet.detect(result); 20 | charset = detectedCharset ? detectedCharset.encoding : 'utf-8'; 21 | } 22 | 23 | const decoded = decode(Buffer.from(result, 'binary'), charset); 24 | return res(decoded); 25 | }); 26 | reader.readAsBinaryString(file); 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/ynap-parsers/src/util/read-to-buffer.ts: -------------------------------------------------------------------------------- 1 | export const readToBuffer = (file: File): Promise => { 2 | return new Promise((res, rej) => { 3 | const reader = new FileReader(); 4 | reader.addEventListener('load', () => { 5 | if (reader.result === null) { 6 | rej('Result is null.'); 7 | } 8 | 9 | const result = reader.result! as string; 10 | 11 | if (result.length === 0) { 12 | return res(Buffer.from('')); 13 | } 14 | 15 | return res(Buffer.from(result, 'binary')); 16 | }); 17 | reader.readAsBinaryString(file); 18 | }); 19 | }; 20 | -------------------------------------------------------------------------------- /packages/ynap-parsers/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "lib": ["dom", "es2017"], 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "resolveJsonModule": true, 8 | "outDir": "lib", 9 | "rootDir": "src" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/ynap-web-app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules/ 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage/ 10 | 11 | # gatsby 12 | .cache/ 13 | 14 | # production 15 | public/ 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | -------------------------------------------------------------------------------- /packages/ynap-web-app/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `npm start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `npm test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `npm run build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `npm run eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | -------------------------------------------------------------------------------- /packages/ynap-web-app/gatsby-browser.js: -------------------------------------------------------------------------------- 1 | const toastify = require('react-toastify'); 2 | 3 | export const onServiceWorkerUpdateReady = () => { 4 | toastify.toast.info( 5 | `YNAP has been updated. Please click this message or reload the page to load the latest version.`, 6 | { autoClose: false, onClose: () => window.location.reload() }, 7 | ); 8 | }; 9 | -------------------------------------------------------------------------------- /packages/ynap-web-app/gatsby-config.js: -------------------------------------------------------------------------------- 1 | const dateFns = require('date-fns'); 2 | 3 | module.exports = { 4 | siteMetadata: { 5 | version: require('./package.json').version, 6 | commit: process.env.COMMIT_REF || 'dev', 7 | timestamp: dateFns.format(new Date(), 'yyyy-MM-dd HH:mm:ss'), 8 | }, 9 | plugins: [ 10 | `gatsby-plugin-typescript`, 11 | `gatsby-plugin-react-helmet`, 12 | `gatsby-plugin-styled-components`, 13 | `gatsby-plugin-netlify`, 14 | 'gatsby-plugin-offline', 15 | ], 16 | }; 17 | -------------------------------------------------------------------------------- /packages/ynap-web-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ynap-web-app", 3 | "version": "1.15.0", 4 | "license": "MIT", 5 | "private": true, 6 | "dependencies": { 7 | "@types/jest": "24.9.1", 8 | "@types/node": "18.16.16", 9 | "@types/react": "16.14.60", 10 | "@types/react-dom": "16.9.24", 11 | "file-saver": "2.0.5", 12 | "gatsby": "2.32.13", 13 | "gatsby-plugin-netlify": "2.11.1", 14 | "gatsby-plugin-offline": "3.10.2", 15 | "gatsby-plugin-react-helmet": "3.10.0", 16 | "gatsby-plugin-styled-components": "3.10.0", 17 | "gatsby-plugin-typescript": "2.12.1", 18 | "react": "16.14.0", 19 | "react-dom": "16.14.0", 20 | "react-helmet": "5.2.1", 21 | "react-helmet-async": "1.3.0", 22 | "react-toastify": "5.5.0", 23 | "styled-components": "4.4.1", 24 | "ts-node": "10.9.2", 25 | "typescript": "3.9.10", 26 | "ynap-parsers": "^1.15.0" 27 | }, 28 | "scripts": { 29 | "start": "gatsby develop", 30 | "build": "gatsby build", 31 | "test": "jest" 32 | }, 33 | "eslintConfig": { 34 | "extends": "react-app" 35 | }, 36 | "browserslist": [ 37 | ">0.2%", 38 | "not dead", 39 | "not ie <= 11", 40 | "not op_mini all" 41 | ], 42 | "devDependencies": { 43 | "@types/file-saver": "2.0.7", 44 | "@types/node-fetch": "2.6.4", 45 | "@types/react-helmet": "5.0.27", 46 | "@types/react-helmet-async": "1.0.3", 47 | "@types/react-toastify": "4.0.2", 48 | "@types/styled-components": "4.4.3", 49 | "babel-plugin-styled-components": "1.13.3", 50 | "date-fns": "2.30.0", 51 | "encoding": "0.1.13", 52 | "jest": "24.9.0", 53 | "node-fetch": "2.6.11", 54 | "ts-jest": "24.3.0", 55 | "tslint": "5.20.1" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/ynap-web-app/src/components/github-badge.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled, { keyframes } from 'styled-components'; 3 | 4 | const wave = keyframes` 5 | 0%, 6 | 100% { 7 | transform: rotate(0); 8 | } 9 | 20%, 10 | 60% { 11 | transform: rotate(-25deg); 12 | } 13 | 40%, 14 | 80% { 15 | transform: rotate(10deg); 16 | } 17 | `; 18 | 19 | const Badge = styled.svg` 20 | fill: #1a2a40; 21 | color: #f1f4f9; 22 | position: absolute; 23 | top: 0; 24 | right: 0; 25 | border: 0; 26 | 27 | .octo-arm { 28 | transform-origin: 130px 106px; 29 | } 30 | 31 | &:hover .octo-arm { 32 | animation: ${wave} 560ms ease-in-out; 33 | } 34 | `; 35 | 36 | export const GitHubBadge: React.FC = () => ( 37 | 44 | 57 | 58 | ); 59 | -------------------------------------------------------------------------------- /packages/ynap-web-app/src/components/meta-tags.tsx: -------------------------------------------------------------------------------- 1 | import Helmet from 'react-helmet'; 2 | import React from 'react'; 3 | 4 | interface MetaTagsProps { 5 | title?: string; 6 | description?: string; 7 | } 8 | 9 | const MetaTags: React.FC = ({ title, description }) => { 10 | const pageTitle = title 11 | ? `${title} – You Need A Parser` 12 | : 'You Need A Parser – Convert bank statements for use with YNAB'; 13 | 14 | const pageDescription = 15 | description || 16 | 'YNAP converts CSV files from a variety of sources into a ' + 17 | 'format that can easily be imported into You Need A Budget. ' + 18 | 'Your files will never leave your browser.'; 19 | 20 | return ( 21 | 22 | 23 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {pageTitle} 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | }; 52 | 53 | export default MetaTags; 54 | -------------------------------------------------------------------------------- /packages/ynap-web-app/src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const NotFoundPage = () =>

Not found.

; 4 | 5 | export default NotFoundPage; 6 | -------------------------------------------------------------------------------- /packages/ynap-web-app/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Link, graphql } from 'gatsby'; 3 | import { saveAs } from 'file-saver'; 4 | import styled, { keyframes, css } from 'styled-components'; 5 | import { ToastContainer, toast } from 'react-toastify'; 6 | import 'react-toastify/dist/ReactToastify.css'; 7 | 8 | import '../styles/index.css'; 9 | import { parseFile, parsers, countries } from 'ynap-parsers'; 10 | import MetaTags from '../components/meta-tags'; 11 | import { GitHubBadge } from '../components/github-badge'; 12 | 13 | const pulse = keyframes` 14 | 0% { 15 | box-shadow: 0 0 0 0 rgba(62, 189, 147, 0.2); 16 | } 17 | 70% { 18 | box-shadow: 0 0 0 20px rgba(62, 189, 147, 0); 19 | } 20 | 100% { 21 | box-shadow: 0 0 0 0 rgba(62, 189, 147, 0); 22 | } 23 | `; 24 | 25 | const Container = styled.div<{ uploadHover?: boolean }>` 26 | min-height: 100vh; 27 | display: flex; 28 | align-items: center; 29 | justify-content: center; 30 | flex-direction: column; 31 | text-align: center; 32 | 33 | padding: 2rem; 34 | box-sizing: border-box; 35 | 36 | background: #f1f4f9; 37 | transition: background-color 0.2s; 38 | 39 | > p { 40 | max-width: 40rem; 41 | margin-bottom: 4rem; 42 | } 43 | 44 | ${p => 45 | p.uploadHover && 46 | css` 47 | background-color: hsl(218, 40, 90); 48 | 49 | .arrow-up { 50 | transform: translateY(-1px); 51 | } 52 | 53 | ${DropArea} { 54 | animation: ${pulse} 2s infinite; 55 | border-color: #3ebd93; 56 | } 57 | `} 58 | `; 59 | 60 | const DropArea = styled.div` 61 | display: flex; 62 | align-items: center; 63 | justify-content: center; 64 | flex-direction: column; 65 | 66 | margin-bottom: 2.5rem; 67 | padding: 3rem 4rem; 68 | 69 | background: #fff; 70 | border: 3px #ccc solid; 71 | border-radius: 2rem; 72 | 73 | transition: border-color 0.2s; 74 | `; 75 | 76 | const UploadIcon = styled.svg` 77 | display: block; 78 | width: 6rem; 79 | overflow: visible; 80 | 81 | color: #666; 82 | 83 | .arrow-up { 84 | transition: transform 0.2s; 85 | } 86 | `; 87 | 88 | const Footer = styled.footer` 89 | p { 90 | margin: 0; 91 | 92 | &.small { 93 | margin-top: 0.2rem; 94 | font-size: 80%; 95 | opacity: 0.8; 96 | } 97 | } 98 | `; 99 | 100 | const App: React.FC<{ version: string; commit: string; timestamp: string }> = ({ 101 | version, 102 | commit, 103 | timestamp, 104 | }) => { 105 | const [uploadHover, setUploadHover] = useState(false); 106 | 107 | useEffect(() => { 108 | const enter = (e: DragEvent) => { 109 | setUploadHover(true); 110 | e.preventDefault(); 111 | e.stopPropagation(); 112 | }; 113 | 114 | const leave = (e: DragEvent) => { 115 | setUploadHover(false); 116 | e.preventDefault(); 117 | e.stopPropagation(); 118 | }; 119 | 120 | const drop = async (e: DragEvent) => { 121 | setUploadHover(false); 122 | e.preventDefault(); 123 | e.stopPropagation(); 124 | 125 | const files = Array.from(e.dataTransfer!.files); 126 | let errors: number = 0; 127 | let resultCount: number = 0; 128 | 129 | for (const file of files) { 130 | try { 131 | const result = await parseFile(file); 132 | 133 | for (const parsedFile of result) { 134 | const blob = new Blob([parsedFile.data], { 135 | type: 'text/csv;charset=utf-8', 136 | }); 137 | const fileName = [ 138 | parsedFile.matchedParser.name, 139 | parsedFile.accountName, 140 | 'ynap', 141 | ] 142 | .filter(e => e) 143 | .join('-'); 144 | saveAs(blob, `${fileName}.csv`); 145 | resultCount++; 146 | } 147 | } catch (e) { 148 | errors++; 149 | toast( 150 | <> 151 | The file {file.name} errored: {e.message} 152 | , 153 | { type: 'error' }, 154 | ); 155 | throw e; 156 | } 157 | } 158 | 159 | const successCount = files.length - errors; 160 | if (files.length - errors > 0) { 161 | toast( 162 | <> 163 | Converted {successCount} input{' '} 164 | {successCount === 1 ? 'file' : 'files'} into{' '} 165 | {resultCount} {resultCount === 1 ? 'file' : 'files'} for 166 | YNAB 167 | , 168 | { type: 'success' }, 169 | ); 170 | } 171 | }; 172 | 173 | window.document.body.addEventListener('dragenter', enter); 174 | window.document.body.addEventListener('dragover', enter); 175 | window.document.body.addEventListener('dragleave', leave); 176 | window.document.body.addEventListener('drop', drop); 177 | 178 | return () => { 179 | window.document.body.removeEventListener('dragenter', enter); 180 | window.document.body.removeEventListener('dragover', enter); 181 | window.document.body.removeEventListener('dragleave', leave); 182 | window.document.body.removeEventListener('drop', drop); 183 | }; 184 | }, []); 185 | 186 | return ( 187 | <> 188 | 189 | 190 |

You Need A Parser

191 |

192 | YNAP converts CSV files from a variety of sources into a format that can 193 | easily be imported into{' '} 194 | 195 | You Need A Budget 196 | 197 | . Just drag the files you want to convert into this window. Your files will 198 | never leave your browser. 199 |

200 | 201 | 209 | 210 | 211 | 212 | 213 | 214 | 215 |

{uploadHover ? 'Drop' : 'Drag'} files here to parse

216 |
217 |

218 | YNAP supports {parsers.length} different formats for banks of{' '} 219 | {countries.length} countries, including{' '} 220 | {parsers 221 | .map(p => ( 222 | <> 223 | 224 | {p.name} 225 | 226 | ,{' '} 227 | 228 | )) 229 | .slice(0, 10)} 230 | and more. 231 |

232 | 264 |
265 | 266 | ); 267 | }; 268 | 269 | const Index = ({ data }) => ( 270 | <> 271 | 272 | 273 | 278 | 279 | ); 280 | 281 | export const query = graphql` 282 | { 283 | site { 284 | siteMetadata { 285 | version 286 | commit 287 | timestamp 288 | } 289 | } 290 | } 291 | `; 292 | 293 | export default Index; 294 | -------------------------------------------------------------------------------- /packages/ynap-web-app/src/pages/supported-formats.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import '../styles/index.css'; 5 | 6 | import { parsers, countries } from 'ynap-parsers'; 7 | import countryNames from '../util/countries'; 8 | import MetaTags from '../components/meta-tags'; 9 | import { Link } from 'gatsby'; 10 | 11 | const Container = styled.div` 12 | padding: 4rem 2rem; 13 | max-width: 40rem; 14 | margin: auto; 15 | `; 16 | 17 | const ParserPill = styled.a` 18 | display: inline-block; 19 | padding: 0.1rem 0.8rem; 20 | border-radius: 100px; 21 | margin-right: 0.5rem; 22 | margin-bottom: 0.5rem; 23 | text-decoration: none; 24 | background: #3ebd93; 25 | color: #fff !important; 26 | `; 27 | 28 | const SupportedFormats = () => ( 29 | <> 30 | p.name) 37 | .join(', ')}, and more.`} 38 | /> 39 | 40 | 41 |

42 | You Need A Parser 43 |

44 | 45 |

Supported Formats

46 | 47 | {['international', ...countries].map(c => ( 48 | 49 |

{countryNames[c] || c}

50 |

51 | {parsers 52 | .filter(p => p.country === c) 53 | .map(p => ( 54 | 55 | {p.name} 56 | 57 | ))} 58 |

59 |
60 | ))} 61 |
62 | 63 | ); 64 | 65 | export default SupportedFormats; 66 | -------------------------------------------------------------------------------- /packages/ynap-web-app/src/styles/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, 4 | Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 5 | color: #333; 6 | background: #f1f4f9; 7 | } 8 | 9 | h1 { 10 | font-size: 4rem; 11 | margin: 0; 12 | color: #3ebd93; 13 | text-align: center; 14 | text-transform: uppercase; 15 | } 16 | 17 | p { 18 | font-size: 18px; 19 | line-height: 1.6; 20 | } 21 | 22 | a, 23 | a:visited { 24 | color: #111; 25 | } 26 | 27 | h1 a, 28 | h1 a:visited { 29 | color: #3ebd93; 30 | text-decoration: none; 31 | } 32 | -------------------------------------------------------------------------------- /packages/ynap-web-app/src/util/countries-map.json: -------------------------------------------------------------------------------- 1 | { 2 | "af": "Afghanistan", 3 | "ax": "Åland Islands", 4 | "al": "Albania", 5 | "dz": "Algeria", 6 | "as": "American Samoa", 7 | "ad": "AndorrA", 8 | "ao": "Angola", 9 | "ai": "Anguilla", 10 | "aq": "Antarctica", 11 | "ag": "Antigua and Barbuda", 12 | "ar": "Argentina", 13 | "am": "Armenia", 14 | "aw": "Aruba", 15 | "au": "Australia", 16 | "at": "Austria", 17 | "az": "Azerbaijan", 18 | "bs": "Bahamas", 19 | "bh": "Bahrain", 20 | "bd": "Bangladesh", 21 | "bb": "Barbados", 22 | "by": "Belarus", 23 | "be": "Belgium", 24 | "bz": "Belize", 25 | "bj": "Benin", 26 | "bm": "Bermuda", 27 | "bt": "Bhutan", 28 | "bo": "Bolivia", 29 | "ba": "Bosnia and Herzegovina", 30 | "bw": "Botswana", 31 | "bv": "Bouvet Island", 32 | "br": "Brazil", 33 | "io": "British Indian Ocean Territory", 34 | "bn": "Brunei Darussalam", 35 | "bg": "Bulgaria", 36 | "bf": "Burkina Faso", 37 | "bi": "Burundi", 38 | "kh": "Cambodia", 39 | "cm": "Cameroon", 40 | "ca": "Canada", 41 | "cv": "Cape Verde", 42 | "ky": "Cayman Islands", 43 | "cf": "Central African Republic", 44 | "td": "Chad", 45 | "cl": "Chile", 46 | "cn": "China", 47 | "cx": "Christmas Island", 48 | "cc": "Cocos (Keeling) Islands", 49 | "co": "Colombia", 50 | "km": "Comoros", 51 | "cg": "Congo", 52 | "cd": "Congo, The Democratic Republic of the", 53 | "ck": "Cook Islands", 54 | "cr": "Costa Rica", 55 | "ci": "Cote D'Ivoire", 56 | "hr": "Croatia", 57 | "cu": "Cuba", 58 | "cy": "Cyprus", 59 | "cz": "Czech Republic", 60 | "dk": "Denmark", 61 | "dj": "Djibouti", 62 | "dm": "Dominica", 63 | "do": "Dominican Republic", 64 | "ec": "Ecuador", 65 | "eg": "Egypt", 66 | "sv": "El Salvador", 67 | "gq": "Equatorial Guinea", 68 | "er": "Eritrea", 69 | "ee": "Estonia", 70 | "et": "Ethiopia", 71 | "fk": "Falkland Islands (Malvinas)", 72 | "fo": "Faroe Islands", 73 | "fj": "Fiji", 74 | "fi": "Finland", 75 | "fr": "France", 76 | "gf": "French Guiana", 77 | "pf": "French Polynesia", 78 | "tf": "French Southern Territories", 79 | "ga": "Gabon", 80 | "gm": "Gambia", 81 | "ge": "Georgia", 82 | "de": "Germany", 83 | "gh": "Ghana", 84 | "gi": "Gibraltar", 85 | "gr": "Greece", 86 | "gl": "Greenland", 87 | "gd": "Grenada", 88 | "gp": "Guadeloupe", 89 | "gu": "Guam", 90 | "gt": "Guatemala", 91 | "gg": "Guernsey", 92 | "gn": "Guinea", 93 | "gw": "Guinea-Bissau", 94 | "gy": "Guyana", 95 | "ht": "Haiti", 96 | "hm": "Heard Island and Mcdonald Islands", 97 | "va": "Holy See (Vatican City State)", 98 | "hn": "Honduras", 99 | "hk": "Hong Kong", 100 | "hu": "Hungary", 101 | "is": "Iceland", 102 | "in": "India", 103 | "id": "Indonesia", 104 | "ir": "Iran, Islamic Republic Of", 105 | "iq": "Iraq", 106 | "ie": "Ireland", 107 | "im": "Isle of Man", 108 | "il": "Israel", 109 | "it": "Italy", 110 | "jm": "Jamaica", 111 | "jp": "Japan", 112 | "je": "Jersey", 113 | "jo": "Jordan", 114 | "kz": "Kazakhstan", 115 | "ke": "Kenya", 116 | "ki": "Kiribati", 117 | "kp": "Korea, Democratic People's Republic of", 118 | "kr": "Korea, Republic of", 119 | "kw": "Kuwait", 120 | "kg": "Kyrgyzstan", 121 | "la": "Lao People's Democratic Republic", 122 | "lv": "Latvia", 123 | "lb": "Lebanon", 124 | "ls": "Lesotho", 125 | "lr": "Liberia", 126 | "ly": "Libyan Arab Jamahiriya", 127 | "li": "Liechtenstein", 128 | "lt": "Lithuania", 129 | "lu": "Luxembourg", 130 | "mo": "Macao", 131 | "mk": "Macedonia, The Former Yugoslav Republic of", 132 | "mg": "Madagascar", 133 | "mw": "Malawi", 134 | "my": "Malaysia", 135 | "mv": "Maldives", 136 | "ml": "Mali", 137 | "mt": "Malta", 138 | "mh": "Marshall Islands", 139 | "mq": "Martinique", 140 | "mr": "Mauritania", 141 | "mu": "Mauritius", 142 | "yt": "Mayotte", 143 | "mx": "Mexico", 144 | "fm": "Micronesia, Federated States of", 145 | "md": "Moldova, Republic of", 146 | "mc": "Monaco", 147 | "mn": "Mongolia", 148 | "ms": "Montserrat", 149 | "ma": "Morocco", 150 | "mz": "Mozambique", 151 | "mm": "Myanmar", 152 | "na": "Namibia", 153 | "nr": "Nauru", 154 | "np": "Nepal", 155 | "nl": "Netherlands", 156 | "an": "Netherlands Antilles", 157 | "nc": "New Caledonia", 158 | "nz": "New Zealand", 159 | "ni": "Nicaragua", 160 | "ne": "Niger", 161 | "ng": "Nigeria", 162 | "nu": "Niue", 163 | "nf": "Norfolk Island", 164 | "mp": "Northern Mariana Islands", 165 | "no": "Norway", 166 | "om": "Oman", 167 | "pk": "Pakistan", 168 | "pw": "Palau", 169 | "ps": "Palestinian Territory, Occupied", 170 | "pa": "Panama", 171 | "pg": "Papua New Guinea", 172 | "py": "Paraguay", 173 | "pe": "Peru", 174 | "ph": "Philippines", 175 | "pn": "Pitcairn", 176 | "pl": "Poland", 177 | "pt": "Portugal", 178 | "pr": "Puerto Rico", 179 | "qa": "Qatar", 180 | "re": "Reunion", 181 | "ro": "Romania", 182 | "ru": "Russian Federation", 183 | "rw": "RWANDA", 184 | "sh": "Saint Helena", 185 | "kn": "Saint Kitts and Nevis", 186 | "lc": "Saint Lucia", 187 | "pm": "Saint Pierre and Miquelon", 188 | "vc": "Saint Vincent and the Grenadines", 189 | "ws": "Samoa", 190 | "sm": "San Marino", 191 | "st": "Sao Tome and Principe", 192 | "sa": "Saudi Arabia", 193 | "sn": "Senegal", 194 | "cs": "Serbia and Montenegro", 195 | "sc": "Seychelles", 196 | "sl": "Sierra Leone", 197 | "sg": "Singapore", 198 | "sk": "Slovakia", 199 | "si": "Slovenia", 200 | "sb": "Solomon Islands", 201 | "so": "Somalia", 202 | "za": "South Africa", 203 | "gs": "South Georgia and the South Sandwich Islands", 204 | "es": "Spain", 205 | "lk": "Sri Lanka", 206 | "sd": "Sudan", 207 | "sr": "Suriname", 208 | "sj": "Svalbard and Jan Mayen", 209 | "sz": "Swaziland", 210 | "se": "Sweden", 211 | "ch": "Switzerland", 212 | "sy": "Syrian Arab Republic", 213 | "tw": "Taiwan, Province of China", 214 | "tj": "Tajikistan", 215 | "tz": "Tanzania, United Republic of", 216 | "th": "Thailand", 217 | "tl": "Timor-Leste", 218 | "tg": "Togo", 219 | "tk": "Tokelau", 220 | "to": "Tonga", 221 | "tt": "Trinidad and Tobago", 222 | "tn": "Tunisia", 223 | "tr": "Turkey", 224 | "tm": "Turkmenistan", 225 | "tc": "Turks and Caicos Islands", 226 | "tv": "Tuvalu", 227 | "ug": "Uganda", 228 | "ua": "Ukraine", 229 | "ae": "United Arab Emirates", 230 | "uk": "United Kingdom", 231 | "us": "United States", 232 | "um": "United States Minor Outlying Islands", 233 | "uy": "Uruguay", 234 | "uz": "Uzbekistan", 235 | "vu": "Vanuatu", 236 | "ve": "Venezuela", 237 | "vn": "Viet Nam", 238 | "vg": "Virgin Islands, British", 239 | "vi": "Virgin Islands, U.S.", 240 | "wf": "Wallis and Futuna", 241 | "eh": "Western Sahara", 242 | "ye": "Yemen", 243 | "zm": "Zambia", 244 | "zw": "Zimbabwe" 245 | } 246 | -------------------------------------------------------------------------------- /packages/ynap-web-app/src/util/countries.ts: -------------------------------------------------------------------------------- 1 | import countries from './countries-map.json'; 2 | 3 | export default { 4 | ...countries, 5 | international: 'International', 6 | }; 7 | -------------------------------------------------------------------------------- /packages/ynap-web-app/static/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-web-app/static/android-chrome-192x192.png -------------------------------------------------------------------------------- /packages/ynap-web-app/static/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-web-app/static/android-chrome-512x512.png -------------------------------------------------------------------------------- /packages/ynap-web-app/static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-web-app/static/apple-touch-icon.png -------------------------------------------------------------------------------- /packages/ynap-web-app/static/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #3ebd93 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /packages/ynap-web-app/static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-web-app/static/favicon-16x16.png -------------------------------------------------------------------------------- /packages/ynap-web-app/static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-web-app/static/favicon-32x32.png -------------------------------------------------------------------------------- /packages/ynap-web-app/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-web-app/static/favicon.ico -------------------------------------------------------------------------------- /packages/ynap-web-app/static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "You Need A Parser", 3 | "short_name": "YNAP", 4 | "start_url": ".", 5 | "icons": [ 6 | { 7 | "src": "/android-chrome-192x192.png", 8 | "sizes": "192x192", 9 | "type": "image/png" 10 | }, 11 | { 12 | "src": "/android-chrome-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png" 15 | } 16 | ], 17 | "theme_color": "#f1f4f9", 18 | "background_color": "#f1f4f9", 19 | "display": "standalone" 20 | } 21 | -------------------------------------------------------------------------------- /packages/ynap-web-app/static/meta-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-web-app/static/meta-image.jpg -------------------------------------------------------------------------------- /packages/ynap-web-app/static/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leolabs/you-need-a-parser/afcebd9cb484f7a57a13b72c5f2a2ea398acd17b/packages/ynap-web-app/static/mstile-150x150.png -------------------------------------------------------------------------------- /packages/ynap-web-app/static/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 19 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /packages/ynap-web-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./src/**/*", 4 | "../ynap-parsers/src/util/papaparse.ts", 5 | "../ynap-parsers/src/util/jschardet.d.ts" 6 | ], 7 | "compilerOptions": { 8 | "target": "esnext", 9 | "module": "commonjs", 10 | "lib": ["dom", "es2017"], 11 | "jsx": "preserve", 12 | "strict": true, 13 | "esModuleInterop": true, 14 | "experimentalDecorators": true, 15 | "emitDecoratorMetadata": true, 16 | "resolveJsonModule": true, 17 | "noEmit": true, 18 | "skipLibCheck": true, 19 | "noImplicitAny": false, 20 | "importHelpers": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "automerge": true, 4 | "major": { 5 | "automerge": false 6 | } 7 | } 8 | --------------------------------------------------------------------------------