├── .node-version ├── .gitignore ├── .prettierrc ├── typedoc.json ├── semantic-release-rules.js ├── src ├── __mocks__ │ ├── amazon-order-reports-api.ts │ ├── ynab-client.ts │ └── config.ts ├── tsconfig.json ├── support-notice.ts ├── logger.ts ├── index.ts ├── puppeteer.ts ├── cache.ts ├── __snapshots__ │ └── amazon.test.ts.snap ├── ynab.ts ├── cache.test.ts ├── config.ts ├── ynab.test.ts ├── amazon.ts └── amazon.test.ts ├── .commitlintrc.js ├── jest.config.js ├── SUPPORTERS.md ├── tsconfig.json ├── .eslintrc.js ├── .github └── workflows │ ├── lint.yml │ ├── release.yml │ └── test.yml ├── LICENSE ├── output-licenses.ts ├── .cz.json ├── package.json ├── CHANGELOG.md └── README.md /.node-version: -------------------------------------------------------------------------------- 1 | 14.15.3 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | buildcache 3 | coverage 4 | docs 5 | debug 6 | lib 7 | licenses 8 | node_modules 9 | pkg 10 | test-output 11 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "printWidth": 100 7 | } 8 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["src/index.ts"], 3 | "excludePrivate": true, 4 | "out": "docs", 5 | "theme": "minimal", 6 | "tsconfig": "src/tsconfig.json" 7 | } 8 | -------------------------------------------------------------------------------- /semantic-release-rules.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { type: 'docs', scope: 'readme', release: 'patch' }, 3 | { type: 'refactor', release: 'patch' }, 4 | { message: '*force-release*', release: 'patch' }, 5 | ]; 6 | -------------------------------------------------------------------------------- /src/__mocks__/amazon-order-reports-api.ts: -------------------------------------------------------------------------------- 1 | export const mocks = { 2 | getItems: jest.fn(), 3 | getRefunds: jest.fn(), 4 | getShipments: jest.fn(), 5 | stop: jest.fn(), 6 | }; 7 | 8 | export const AmazonOrderReportsApi = jest.fn(() => mocks); 9 | -------------------------------------------------------------------------------- /.commitlintrc.js: -------------------------------------------------------------------------------- 1 | const config = require('@commitlint/config-conventional'); 2 | 3 | module.exports = { 4 | extends: ['@commitlint/config-conventional'], 5 | rules: { 6 | 'type-enum': [2, 'always', [...config.rules['type-enum'][2], 'wip']] 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | reporters: ['default', ['jest-junit', { outputDirectory: 'test-output' }]], 5 | coverageDirectory: '../coverage', 6 | rootDir: './src', 7 | testMatch: ['**/*.test.ts'], 8 | collectCoverageFrom: ['**/*.ts'] 9 | }; 10 | -------------------------------------------------------------------------------- /SUPPORTERS.md: -------------------------------------------------------------------------------- 1 | # Supporters 2 | 3 | These supporters have graciously donated to the [Oregon Food Bank](https://oregonfoodbank.org/donate) in support of this project. Donate $50 or more and [send me a screenshot](mailto:s@starsprung.com) of your donation and I'll add your name to this list. 4 | 5 | A big thank you to: 6 | 7 | - Peter Couvares 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "lib": ["ES2020", "dom"], 5 | "moduleResolution": "node", 6 | "outDir": ".", 7 | "resolveJsonModule": true, 8 | "rootDir": ".", 9 | "target": "ES2020", 10 | "tsBuildInfoFile": "./buildcache/tsconfig.tsbuildinfo" 11 | }, 12 | "files": ["package.json"] 13 | } 14 | -------------------------------------------------------------------------------- /src/__mocks__/ynab-client.ts: -------------------------------------------------------------------------------- 1 | import { SaveTransaction } from 'ynab-client'; 2 | 3 | export const mocks = { 4 | accounts: { 5 | getAccounts: jest.fn(), 6 | }, 7 | budgets: { 8 | getBudgets: jest.fn(), 9 | }, 10 | transactions: { 11 | createTransactions: jest.fn(), 12 | }, 13 | }; 14 | 15 | export const API = jest.fn(() => mocks); 16 | 17 | export { SaveTransaction }; 18 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es2020: true, 4 | }, 5 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 6 | parser: '@typescript-eslint/parser', 7 | parserOptions: { 8 | ecmaVersion: 12, 9 | sourceType: 'module', 10 | }, 11 | plugins: ['@typescript-eslint'], 12 | rules: { 13 | '@typescript-eslint/no-unused-vars': ['error', { varsIgnorePattern: '^_' }], 14 | 'no-empty': 'off', 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "esModuleInterop": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "lib": ["ES2020", "dom"], 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "outDir": "../lib", 10 | "resolveJsonModule": true, 11 | "rootDir": "./", 12 | "skipLibCheck": true, 13 | "strict": true, 14 | "target": "ES2020" 15 | }, 16 | "references": [{ "path": "../" }], 17 | "exclude": ["**/*.test.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /src/support-notice.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | const SUPPORT_NOTICE = ` 4 | If you'd like to support this project, please consider donating to Oregon Food Bank: ${chalk.bold( 5 | 'https://oregonfoodbank.org', 6 | )} 7 | Donate more than $50 and send me (s@starsprung.com) a screenshot of your donation and I'll add you to a list of supporters! 8 | `; 9 | 10 | const supportNotice = (): void => { 11 | if (process.stdout.isTTY) { 12 | console.log(SUPPORT_NOTICE); 13 | } 14 | }; 15 | 16 | export default supportNotice; 17 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import * as winston from 'winston'; 2 | import { getConfig } from './config'; 3 | 4 | const config = getConfig(); 5 | 6 | const logger = winston.createLogger({ 7 | format: winston.format.combine( 8 | winston.format.metadata(), 9 | winston.format.timestamp(), 10 | winston.format.json() 11 | ) 12 | }); 13 | 14 | logger.add( 15 | new winston.transports.Console( 16 | config.logLevel === 'none' || process.env.NODE_ENV === 'test' 17 | ? { silent: true } 18 | : { 19 | level: config.logLevel 20 | } 21 | ) 22 | ); 23 | 24 | export default logger; 25 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: push 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout code 10 | uses: actions/checkout@v2 11 | 12 | - name: Setup Node 13 | uses: actions/setup-node@v1 14 | with: 15 | node-version: 14.x 16 | 17 | - name: Cache Node.js modules 18 | uses: actions/cache@v2 19 | with: 20 | path: ~/.npm 21 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 22 | restore-keys: | 23 | ${{ runner.OS }}-node- 24 | ${{ runner.OS }}- 25 | 26 | - name: Install dependencies 27 | run: npm ci 28 | env: 29 | HUSKY_SKIP_INSTALL: 1 30 | 31 | - name: Run ESLint 32 | run: npm run lint 33 | -------------------------------------------------------------------------------- /src/__mocks__/config.ts: -------------------------------------------------------------------------------- 1 | import { normalize } from 'path'; 2 | import { Config } from '../config'; 3 | 4 | const mocks = { 5 | amazonOtpCode: jest.fn(() => '54321'), 6 | amazonPassword: jest.fn(() => 'pass123456'), 7 | amazonUsername: jest.fn(() => 'user@example.com'), 8 | cacheDir: jest.fn(() => normalize('/path/to/cache/')), 9 | cleared: jest.fn(() => true), 10 | logLevel: jest.fn(() => 'none'), 11 | payee: jest.fn(() => undefined), 12 | ynabAccessToken: jest.fn(() => 'f82918ba-4aa7-4805-b9be-fe5e87eaacf3'), 13 | ynabAccountName: jest.fn(() => 'Amazon.com'), 14 | ynabBudgetName: jest.fn(() => 'Budget'), 15 | }; 16 | 17 | const config = Object.defineProperties( 18 | {}, 19 | Object.fromEntries(Object.entries(mocks).map(([k, v]) => [k, { get: v, configurable: true }])), 20 | ); 21 | 22 | export const getConfig = jest.fn(() => config as Config); 23 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import batch from 'it-batch'; 4 | import { getAmazonTransactions } from './amazon'; 5 | import { makeCacheDir } from './cache'; 6 | import logger from './logger'; 7 | import supportNotice from './support-notice'; 8 | import { addTransactions } from './ynab'; 9 | 10 | const initialize = async (): Promise => { 11 | await makeCacheDir(); 12 | }; 13 | 14 | (async () => { 15 | supportNotice(); 16 | await initialize(); 17 | 18 | logger.info('Retrieving reports from Amazon'); 19 | let count = 0; 20 | for await (const transactionBatch of batch(getAmazonTransactions(), 100)) { 21 | if (count === 0) { 22 | logger.info('Submitting transations to YNAB'); 23 | } 24 | 25 | try { 26 | logger.debug(`Submitting batch of ${transactionBatch.length} transactions`); 27 | await addTransactions(transactionBatch); 28 | count += transactionBatch.length; 29 | } catch (err) { 30 | break; 31 | } 32 | } 33 | 34 | logger.info(`Submitted ${count} transations to YNAB`); 35 | })(); 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Shaun Starsprung 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. 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_run: 4 | workflows: ['Test'] 5 | branches: 6 | - master 7 | types: 8 | - completed 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 16 | uses: actions/checkout@v2 17 | 18 | - name: Setup Node 19 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: 14.x 23 | 24 | - name: Cache Node.js modules 25 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 26 | uses: actions/cache@v2 27 | with: 28 | path: ~/.npm 29 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 30 | restore-keys: | 31 | ${{ runner.OS }}-node- 32 | ${{ runner.OS }}- 33 | 34 | - name: Install dependencies 35 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 36 | run: npm ci 37 | env: 38 | HUSKY_SKIP_INSTALL: 1 39 | 40 | - name: Release 41 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 44 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 45 | run: npx semantic-release 46 | -------------------------------------------------------------------------------- /src/puppeteer.ts: -------------------------------------------------------------------------------- 1 | import { stat } from 'fs/promises'; 2 | import { join } from 'path'; 3 | import puppeteer from 'puppeteer'; 4 | import { getConfig } from './config'; 5 | import logger from './logger'; 6 | 7 | const config = getConfig(); 8 | 9 | const getExternalPuppeteer = async (): Promise => { 10 | const version = ((puppeteer as unknown) as { _preferredRevision: string })._preferredRevision; 11 | const chromiumDir = join(config.cacheDir, 'chromium'); 12 | 13 | const fetcher = puppeteer.createBrowserFetcher({ 14 | path: chromiumDir, 15 | }); 16 | 17 | const { executablePath } = fetcher.revisionInfo(version); 18 | 19 | logger.debug(`Chromium path is: ${executablePath}`); 20 | 21 | try { 22 | if (await stat(executablePath)) { 23 | logger.debug('Using chromium executable found in cache'); 24 | return executablePath; 25 | } 26 | } catch (err) { 27 | if (err.code !== 'ENOENT') { 28 | throw err; 29 | } 30 | } 31 | 32 | logger.info('Chromium not found, downloading it'); 33 | const download = await fetcher.download(version); 34 | logger.info('Finished downloading Chromium'); 35 | return download.executablePath; 36 | }; 37 | 38 | export const getPuppeteerExecutable = async (): Promise => { 39 | if (process.env.PUPPETEER_EXECUTABLE_PATH) { 40 | return process.env.PUPPETEER_EXECUTABLE_PATH; 41 | } 42 | 43 | if ((process as { pkg?: unknown }).pkg) { 44 | logger.debug('Packaged mode, using external chromium'); 45 | return await getExternalPuppeteer(); 46 | } 47 | 48 | return puppeteer.executablePath(); 49 | }; 50 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | os: 11 | - macos-latest 12 | - ubuntu-latest 13 | - windows-latest 14 | node-version: 15 | - 14 16 | - 15 17 | 18 | runs-on: ${{ matrix.os }} 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v2 23 | 24 | - name: Setup Node 25 | uses: actions/setup-node@v1 26 | with: 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | 30 | - name: Cache Node.js modules 31 | uses: actions/cache@v2 32 | with: 33 | path: ~/.npm 34 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 35 | restore-keys: | 36 | ${{ runner.OS }}-node- 37 | ${{ runner.OS }}- 38 | 39 | - name: Install dependencies 40 | run: npm ci 41 | env: 42 | HUSKY_SKIP_INSTALL: 1 43 | 44 | - name: Run tests 45 | run: npm run test:ci 46 | 47 | - name: Publish unit test results 48 | uses: docker://ghcr.io/enricomi/publish-unit-test-result-action:v1.6 49 | if: runner.os == 'Linux' 50 | with: 51 | hide_comments: orphaned commits 52 | check_name: Unit Test Results - ${{ matrix.os }} - node ${{ matrix.node-version }} 53 | comment_title: Unit Test Statistics - ${{ matrix.os }} - node ${{ matrix.node-version }} 54 | github_token: ${{ secrets.GITHUB_TOKEN }} 55 | files: test-output/**/*.xml 56 | -------------------------------------------------------------------------------- /output-licenses.ts: -------------------------------------------------------------------------------- 1 | import { init as licenseChecker, ModuleInfo } from 'license-checker'; 2 | import { promisify } from 'util'; 3 | import { readFile } from 'fs/promises'; 4 | import { join } from 'path'; 5 | 6 | const licenseCheckerPromise = promisify(licenseChecker); 7 | 8 | const separator = `\n\n${''.padEnd(80, '=')}\n\n`; 9 | 10 | (async () => { 11 | const seen = {}; 12 | const packages = Object.entries( 13 | await licenseCheckerPromise({ production: true, start: __dirname }), 14 | ) 15 | .map(([packageNameVersion, details]): [string, ModuleInfo] => [ 16 | packageNameVersion.replace(/^(.+)@.*$/, '$1'), 17 | details, 18 | ]) 19 | .reverse() 20 | .filter(([packageName]) => { 21 | const s = seen[packageName]; 22 | seen[packageName] = true; 23 | return !s; 24 | }) 25 | .reverse(); 26 | 27 | packages.unshift([ 28 | 'Node.js', 29 | { 30 | licenseFile: join(__dirname, 'licenses', 'NODE'), 31 | repository: 'https://github.com/nodejs/node', 32 | }, 33 | ]); 34 | 35 | const licenseList = [await readFile(join(__dirname, 'LICENSE'), { encoding: 'utf-8' })]; 36 | for (const [packageName, { licenses, licenseFile, repository }] of packages) { 37 | licenseList.push( 38 | [ 39 | `This application includes a copy of ${packageName}`, 40 | repository ? `Source code is available from: ${repository}` : [], 41 | `${packageName} is provided under the following license:`, 42 | '', 43 | licenseFile ? await readFile(licenseFile, { encoding: 'utf-8' }) : licenses, 44 | ] 45 | .flat() 46 | .join('\n'), 47 | ); 48 | } 49 | 50 | console.log(licenseList.join(separator)); 51 | })(); 52 | -------------------------------------------------------------------------------- /.cz.json: -------------------------------------------------------------------------------- 1 | { 2 | "path": "./node_modules/cz-conventional-changelog", 3 | "types": { 4 | "feat": { 5 | "description": "A new feature", 6 | "title": "Features" 7 | }, 8 | "fix": { 9 | "description": "A bug fix", 10 | "title": "Bug Fixes" 11 | }, 12 | "docs": { 13 | "description": "Documentation only changes", 14 | "title": "Documentation" 15 | }, 16 | "style": { 17 | "description": "Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)", 18 | "title": "Styles" 19 | }, 20 | "refactor": { 21 | "description": "A code change that neither fixes a bug nor adds a feature", 22 | "title": "Code Refactoring" 23 | }, 24 | "perf": { 25 | "description": "A code change that improves performance", 26 | "title": "Performance Improvements" 27 | }, 28 | "test": { 29 | "description": "Adding missing tests or correcting existing tests", 30 | "title": "Tests" 31 | }, 32 | "build": { 33 | "description": "Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)", 34 | "title": "Builds" 35 | }, 36 | "ci": { 37 | "description": "Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)", 38 | "title": "Continuous Integrations" 39 | }, 40 | "chore": { 41 | "description": "Other changes that don't modify src or test files", 42 | "title": "Chores" 43 | }, 44 | "revert": { 45 | "description": "Reverts a previous commit", 46 | "title": "Reverts" 47 | }, 48 | "wip": { 49 | "description": "Work in progress", 50 | "title": "WIP" 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from 'fs/promises'; 2 | import hasha from 'hasha'; 3 | import makeDir from 'make-dir'; 4 | import { join } from 'path'; 5 | import { getConfig } from './config'; 6 | import logger from './logger'; 7 | 8 | // eslint-disable-next-line 9 | type Serializable = string | number | boolean | object | null; 10 | 11 | const config = getConfig(); 12 | 13 | export const makeCacheDir = async (): Promise => { 14 | logger.debug(`Cache dir is: ${config.cacheDir}`); 15 | await makeDir(config.cacheDir); 16 | }; 17 | 18 | const cacheFilePath = (keys: string | Array) => { 19 | const path = join(config.cacheDir, `${hasha(keys, { algorithm: 'sha256' })}.cache`); 20 | logger.debug(`Path for ${keys} is ${path}`); 21 | return path; 22 | }; 23 | 24 | export const readCache = async ( 25 | keys: string | Array, 26 | maxAge: number = Number.MAX_SAFE_INTEGER, 27 | ): Promise => { 28 | logger.debug(`Reading ${keys} from cache`); 29 | 30 | try { 31 | const content = await readFile(cacheFilePath(keys), { 32 | encoding: 'utf-8', 33 | }); 34 | if (content) { 35 | logger.debug(`Found cached value for ${keys}`); 36 | logger.silly(content); 37 | 38 | const { value, time } = JSON.parse(content); 39 | const age = Date.now() - parseInt(time); 40 | logger.debug(`Cached value for ${keys} age is ${age} ms`); 41 | if (age <= maxAge) { 42 | return value; 43 | } 44 | 45 | logger.debug(`Cached value for ${keys} is too old, ignoring`); 46 | } 47 | } catch (err) { 48 | if (err.code !== 'ENOENT') { 49 | throw err; 50 | } 51 | } 52 | 53 | logger.debug(`No cache value found for ${keys}`); 54 | 55 | return null; 56 | }; 57 | 58 | export const writeCache = async ( 59 | keys: string | Array, 60 | value: Serializable, 61 | ): Promise => { 62 | logger.debug(`Writing ${keys} to cache`); 63 | logger.silly(value); 64 | 65 | await writeFile(cacheFilePath(keys), JSON.stringify({ value, time: Date.now() }, null, 2), { 66 | encoding: 'utf-8', 67 | }); 68 | }; 69 | -------------------------------------------------------------------------------- /src/__snapshots__/amazon.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`amazon getAmazonTransactions should handle unaccounted for adjustments 1`] = ` 4 | Array [ 5 | Object { 6 | "amount": -23750, 7 | "cleared": "cleared", 8 | "date": "2020-01-04", 9 | "import_id": "075048d916825036a0e29dfae3ec77702be3", 10 | "memo": "Another Product", 11 | "payee_name": "This Is A Seller", 12 | }, 13 | Object { 14 | "amount": -2500, 15 | "cleared": "cleared", 16 | "date": "2020-01-04", 17 | "import_id": "31e1b15b3ef2c0923e6f9bfb4db707e36d7f", 18 | "memo": "Shipping for 123-7654321-1234568", 19 | "payee_name": "Amazon.com", 20 | }, 21 | Object { 22 | "amount": 1000, 23 | "cleared": "cleared", 24 | "date": "2020-01-04", 25 | "import_id": "9f796daf73a1963c7fd02bbf4dce613abae7", 26 | "memo": "Promotion for 123-7654321-1234568", 27 | "payee_name": "Amazon.com", 28 | }, 29 | Object { 30 | "amount": -1750, 31 | "cleared": "cleared", 32 | "date": "2020-01-04", 33 | "import_id": "070a213531d829c843df6e25e23e8a7e6c2a", 34 | "memo": "Misc adjustment for 123-7654321-1234568", 35 | "payee_name": "Amazon.com", 36 | }, 37 | ] 38 | `; 39 | 40 | exports[`amazon getAmazonTransactions should return transactions from amazon 1`] = ` 41 | Array [ 42 | Object { 43 | "amount": -21750, 44 | "cleared": "cleared", 45 | "date": "2020-01-03", 46 | "import_id": "6128e2d42a9b71f2680e46bd90c53fcdb8fe", 47 | "memo": "Some Product", 48 | "payee_name": "This Is A Seller", 49 | }, 50 | Object { 51 | "amount": 75980, 52 | "cleared": "cleared", 53 | "date": "2020-01-25", 54 | "import_id": "adbc2ff9b8e772ce931d07a7a07db1f0b53c", 55 | "memo": "2 x Some kind of coat", 56 | "payee_name": "A Seller", 57 | }, 58 | Object { 59 | "amount": 80180, 60 | "cleared": "cleared", 61 | "date": "2020-01-25", 62 | "import_id": "24b8b315a1a244e481ada50682431b720245", 63 | "memo": "2 x Some kind of coat", 64 | "payee_name": "A Seller", 65 | }, 66 | Object { 67 | "amount": -17990, 68 | "cleared": "cleared", 69 | "date": "2020-02-22", 70 | "import_id": "58b86c94e423511cc8791c294f574c51f5e6", 71 | "memo": "Shipping for 112-1234569-3333333", 72 | "payee_name": "Amazon.com", 73 | }, 74 | Object { 75 | "amount": 10510, 76 | "cleared": "cleared", 77 | "date": "2020-02-22", 78 | "import_id": "ab178dd06eeec28f129b1988305e0d4ee01d", 79 | "memo": "Promotion for 112-1234569-3333333", 80 | "payee_name": "Amazon.com", 81 | }, 82 | ] 83 | `; 84 | -------------------------------------------------------------------------------- /src/ynab.ts: -------------------------------------------------------------------------------- 1 | import { API as YnabApi, ErrorResponse } from 'ynab-client'; 2 | import { readCache, writeCache } from './cache'; 3 | import { AmazonTransaction } from './amazon'; 4 | import { getConfig } from './config'; 5 | import logger from './logger'; 6 | 7 | const MAX_YNAB_CACHE_AGE = 60 * 60 * 1000; 8 | 9 | const config = getConfig(); 10 | 11 | type AccountId = string; 12 | const isAccountId = (val: unknown): val is AccountId => typeof val === 'string'; 13 | 14 | type BudgetId = string; 15 | const isBudgetid = (val: unknown): val is BudgetId => typeof val === 'string'; 16 | 17 | const isErrorResponse = (val: unknown): val is ErrorResponse => 18 | (val as ErrorResponse)?.error?.detail === 'string'; 19 | 20 | export const getBudgetId = async (budgetName: string): Promise => { 21 | const ynabApi = new YnabApi(config.ynabAccessToken); 22 | 23 | const cacheKey = [config.ynabAccessToken, budgetName]; 24 | const cached = await readCache(cacheKey, MAX_YNAB_CACHE_AGE); 25 | 26 | if (isBudgetid(cached)) { 27 | logger.debug('Using cached budget ID'); 28 | return cached; 29 | } 30 | 31 | logger.debug('No cached budget ID found'); 32 | 33 | const budgetsResponse = await ynabApi.budgets.getBudgets(); 34 | const budgets = budgetsResponse?.data?.budgets ?? []; 35 | const budget = budgets.find(({ name }) => name === budgetName); 36 | 37 | if (!budget) { 38 | throw new Error(`Unable to find budget with name: ${budgetName}`); 39 | } 40 | 41 | await writeCache(cacheKey, budget.id); 42 | 43 | return budget.id; 44 | }; 45 | 46 | export const getAccountId = async (budgetName: string, accountName: string): Promise => { 47 | const ynabApi = new YnabApi(config.ynabAccessToken); 48 | 49 | const cacheKey = [config.ynabAccessToken, budgetName, accountName]; 50 | const cached = await readCache(cacheKey, MAX_YNAB_CACHE_AGE); 51 | 52 | if (isAccountId(cached)) { 53 | logger.debug('Using cached account ID'); 54 | return cached; 55 | } 56 | 57 | logger.debug('No cached account ID found'); 58 | 59 | const budgetId = await getBudgetId(budgetName); 60 | 61 | const accountsResponse = await ynabApi.accounts.getAccounts(budgetId); 62 | const accounts = accountsResponse?.data?.accounts ?? []; 63 | const account = accounts.find(({ name }) => name === accountName); 64 | 65 | if (!account) { 66 | throw new Error(`Unable to find account with name: ${accountName}`); 67 | } 68 | 69 | await writeCache(cacheKey, account.id); 70 | 71 | return account.id; 72 | }; 73 | 74 | export const addTransactions = async (transactions: Array): Promise => { 75 | const ynabApi = new YnabApi(config.ynabAccessToken); 76 | 77 | const budgetId = await getBudgetId(config.ynabBudgetName); 78 | const accountId = await getAccountId(config.ynabBudgetName, config.ynabAccountName); 79 | 80 | try { 81 | await ynabApi.transactions.createTransactions(budgetId, { 82 | transactions: transactions.map((t) => ({ ...t, account_id: accountId })), 83 | }); 84 | } catch (err) { 85 | if (isErrorResponse(err)) { 86 | logger.error(`Error during YNAB API call: ${err.error.detail}`); 87 | } 88 | 89 | throw err; 90 | } 91 | }; 92 | -------------------------------------------------------------------------------- /src/cache.test.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from 'fs/promises'; 2 | import makeDir from 'make-dir'; 3 | import mockdate from 'mockdate'; 4 | import { normalize } from 'path'; 5 | import { mocked } from 'ts-jest/utils'; 6 | import { makeCacheDir, readCache, writeCache } from './cache'; 7 | 8 | jest.mock('fs/promises'); 9 | jest.mock('make-dir'); 10 | jest.mock('./config'); 11 | 12 | describe('cache', () => { 13 | beforeEach(() => { 14 | jest.clearAllMocks(); 15 | mockdate.set('2021-01-31'); 16 | }); 17 | 18 | afterEach(() => { 19 | mockdate.reset(); 20 | }); 21 | 22 | describe('makeCacheDir', () => { 23 | it('should create the cache directory at the configured path', async () => { 24 | await makeCacheDir(); 25 | 26 | expect(mocked(makeDir)).toBeCalledWith(normalize('/path/to/cache/')); 27 | }); 28 | }); 29 | 30 | describe('readCache', () => { 31 | it('should return null if file is not found', async () => { 32 | mocked(readFile).mockRejectedValueOnce({ code: 'ENOENT' }); 33 | 34 | const result = await readCache(['test', 'abc']); 35 | 36 | expect(mocked(readFile)).toBeCalledWith( 37 | normalize( 38 | '/path/to/cache/e681dcffa482964ddbaaf5bc5aafaa3f055cc5eb5c8a5d389ace0ca8659c766f.cache', 39 | ), 40 | { encoding: 'utf-8' }, 41 | ); 42 | expect(result).toEqual(null); 43 | }); 44 | 45 | it('should throw on non-ENOENT errors', async () => { 46 | mocked(readFile).mockRejectedValueOnce(new Error('something bad happened')); 47 | await expect(readCache(['test', 'abc'])).rejects.toThrow('something bad happened'); 48 | }); 49 | 50 | it('should return stored value if one exists', async () => { 51 | mocked(readFile).mockResolvedValueOnce( 52 | '{ "value": { "somevalue": 123 }, "time": 1609550806034 }', 53 | ); 54 | 55 | const result = await readCache('qwerty'); 56 | expect(mocked(readFile)).toBeCalledWith( 57 | normalize( 58 | '/path/to/cache/65e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5.cache', 59 | ), 60 | { encoding: 'utf-8' }, 61 | ); 62 | expect(result).toEqual({ somevalue: 123 }); 63 | }); 64 | 65 | it('should return stored value if younger than maxAge', async () => { 66 | mocked(readFile).mockResolvedValueOnce( 67 | '{ "value": { "test": "asdf" }, "time": 1611964800000 }', 68 | ); 69 | 70 | const result = await readCache('qwerty', 24 * 60 * 60 * 1000); 71 | expect(result).toEqual({ test: 'asdf' }); 72 | }); 73 | 74 | it('should not return stored value if older than maxAge', async () => { 75 | mocked(readFile).mockResolvedValueOnce( 76 | '{ "value": { "test": "asdf" }, "time": 1611964799999 }', 77 | ); 78 | 79 | const result = await readCache('qwerty', 24 * 60 * 60 * 1000); 80 | expect(result).toEqual(null); 81 | }); 82 | }); 83 | 84 | describe('writeCache', () => { 85 | it('should write value to file', async () => { 86 | await writeCache('zxcv', 1234); 87 | 88 | expect(mocked(writeFile)).toBeCalledWith( 89 | normalize( 90 | '/path/to/cache/7020e57625b6a6695ffd51ed494fbfc56c699eaceca4e77bf7ea590c7ebf3879.cache', 91 | ), 92 | JSON.stringify({ value: 1234, time: 1612051200000 }, null, 2), 93 | { encoding: 'utf-8' }, 94 | ); 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { version } from '../package.json'; 2 | import envPaths from 'env-paths'; 3 | import { readFileSync } from 'fs'; 4 | import { join } from 'path'; 5 | import yargs from 'yargs'; 6 | 7 | const CONFIG_FILE = 'config.json'; 8 | 9 | export interface Config { 10 | amazonUsername?: string; 11 | amazonPassword?: string; 12 | amazonOtpCode?: string; 13 | amazonOtpSecret?: string; 14 | cacheDir: string; 15 | configDir: string; 16 | cleared: boolean; 17 | debugMode: boolean; 18 | logLevel: string; 19 | payee: string; 20 | startDate: string; 21 | ynabBudgetName: string; 22 | ynabAccountName: string; 23 | ynabAccessToken: string; 24 | } 25 | 26 | let config: Config; 27 | 28 | export const getConfig = (): Config => { 29 | if (config) { 30 | return config; 31 | } 32 | 33 | const { cache: defaultCacheDir, config: defaultConfigDir } = envPaths('amazon-ynab-sync', { 34 | suffix: '', 35 | }); 36 | 37 | return (config = { 38 | ...((yargs(process.argv.slice(2)) 39 | .usage('Usage: $0 [options]') 40 | .env() 41 | .version(version) 42 | .option('amazon-otp-code', { 43 | describe: 'Amazon OTP/2SV code', 44 | type: 'string', 45 | }) 46 | .option('amazon-otp-secret', { 47 | describe: 'Amazon OTP/2SV secret', 48 | type: 'string', 49 | }) 50 | .option('amazon-password', { 51 | describe: 'Amazon account password', 52 | type: 'string', 53 | }) 54 | .option('amazon-username', { 55 | describe: 'Amazon account username', 56 | type: 'string', 57 | }) 58 | .option('cache-dir', { 59 | default: defaultCacheDir, 60 | describe: 'Location of cache directory', 61 | type: 'string', 62 | }) 63 | .option('config-dir', { 64 | config: true, 65 | configParser: (path: string): Partial => { 66 | try { 67 | const content = readFileSync(join(path, CONFIG_FILE), { encoding: 'utf-8' }); 68 | return JSON.parse(content); 69 | } catch { 70 | return {}; 71 | } 72 | }, 73 | default: defaultConfigDir, 74 | describe: 'Location of config directory', 75 | type: 'string', 76 | }) 77 | .option('cleared', { 78 | default: true, 79 | describe: 'Whether transactions should be added as cleared by default', 80 | type: 'boolean', 81 | }) 82 | .option('debug-mode', { 83 | default: false, 84 | describe: 'Run internal browser in visible mode and run in slo-mo mode', 85 | type: 'boolean', 86 | }) 87 | .option('log-level', { 88 | choices: ['debug', 'info', 'error', 'none', 'silly'], 89 | default: 'info', 90 | describe: 'Level of logs to output', 91 | type: 'string', 92 | }) 93 | .option('payee', { 94 | describe: 'Override the "Payee" field in YNAB with this value', 95 | type: 'string', 96 | }) 97 | .options('start-date', { 98 | describe: 'Sync transactions that occur after this date', 99 | default: (() => { 100 | const d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); 101 | return [ 102 | d.getFullYear(), 103 | `${d.getMonth() + 1}`.padStart(2, '0'), 104 | `${d.getDate()}`.padStart(2, '0'), 105 | ].join('-'); 106 | })(), 107 | type: 'string', 108 | }) 109 | .option('ynab-access-token', { 110 | demandOption: true, 111 | describe: 'YNAB access token for accessing your account', 112 | type: 'string', 113 | }) 114 | .option('ynab-account-name', { 115 | demandOption: true, 116 | describe: 'Account to use for storing Amazon transactions', 117 | type: 'string', 118 | }) 119 | .option('ynab-budget-name', { 120 | demandOption: true, 121 | describe: 'Budget to use for storing Amazon transactions', 122 | type: 'string', 123 | }).argv as unknown) as Config), 124 | }); 125 | }; 126 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amazon-ynab-sync", 3 | "version": "2.0.0", 4 | "description": "Sync Amazon.com orders to You Need a Budget", 5 | "keywords": [ 6 | "ynab", 7 | "youneedabudget", 8 | "amazon" 9 | ], 10 | "main": "lib/index.js", 11 | "types": "lib/index.d.ts", 12 | "bin": { 13 | "amazon-ynab-sync": "lib/index.js" 14 | }, 15 | "scripts": { 16 | "build": "tsc -b src", 17 | "clean": "rimraf -rf buildcache coverage licenses lib pkg test-output", 18 | "commit": "cz", 19 | "coverage": "jest --coverage --no-cache", 20 | "docs": "typedoc", 21 | "gen-licenses": "mkdir -p licenses && curl -s https://raw.githubusercontent.com/nodejs/node/master/LICENSE > licenses/NODE && ts-node output-licenses.ts > licenses/LICENSES.txt", 22 | "lint": "eslint src", 23 | "pkg": "for PLATFORM in macos linux win; do pkg -t \"node14-${PLATFORM}-x64\" --out-path \"pkg/${npm_package_name}-${PLATFORM}\" .; done", 24 | "postpkg": "npm run gen-licenses && cd pkg && for PKG in *; do cp ../licenses/LICENSES.txt \"${PKG}\" && tar czf \"${PKG}.tar.gz\" \"${PKG}\"; zip -r \"${PKG}.zip\" \"${PKG}\"; done", 25 | "prepack": "npm run clean && npm run build", 26 | "postpack": "npm run pkg", 27 | "start": "node lib/index.js", 28 | "start:dev": "ts-node --dir src index.ts", 29 | "test": "jest", 30 | "test:ci": "jest --ci" 31 | }, 32 | "author": "Shaun Starsprung ", 33 | "repository": "github:starsprung/amazon-ynab-sync", 34 | "license": "MIT", 35 | "engines": { 36 | "node": ">=14.15.0", 37 | "npm": ">=6.9.0" 38 | }, 39 | "dependencies": { 40 | "amazon-order-reports-api": "^3.3.4", 41 | "chalk": "^4.1.0", 42 | "env-paths": "^2.2.0", 43 | "hasha": "^5.2.2", 44 | "inquirer": "^6.5.2", 45 | "it-batch": "^1.0.6", 46 | "lodash.get": "^4.4.2", 47 | "make-dir": "^3.1.0", 48 | "puppeteer": "^5.5.0", 49 | "puppeteer-extra-plugin-stealth": "^2.6.5", 50 | "winston": "^3.3.3", 51 | "yargs": "^15.4.1", 52 | "ynab-client": "npm:ynab@^1.21.0" 53 | }, 54 | "devDependencies": { 55 | "@commitlint/cli": "^11.0.0", 56 | "@commitlint/config-conventional": "^11.0.0", 57 | "@semantic-release/changelog": "^5.0.1", 58 | "@semantic-release/commit-analyzer": "^8.0.1", 59 | "@semantic-release/git": "^9.0.0", 60 | "@semantic-release/github": "^7.2.0", 61 | "@semantic-release/npm": "^7.0.9", 62 | "@semantic-release/release-notes-generator": "^9.0.1", 63 | "@types/inquirer": "^7.3.1", 64 | "@types/license-checker": "^25.0.1", 65 | "@types/lodash.get": "^4.4.6", 66 | "@types/luxon": "^1.25.0", 67 | "@types/mockdate": "^2.0.0", 68 | "@types/node": "^14.14.16", 69 | "@types/puppeteer": "^5.4.2", 70 | "@types/yargs": "^15.0.12", 71 | "@typescript-eslint/eslint-plugin": "^4.11.0", 72 | "@typescript-eslint/parser": "^4.11.0", 73 | "commitizen": "^4.2.2", 74 | "cz-conventional-changelog": "^3.3.0", 75 | "eslint": "^7.16.0", 76 | "husky": "^4.3.6", 77 | "jest": "^26.6.3", 78 | "jest-junit": "^12.0.0", 79 | "license-checker": "^25.0.1", 80 | "mockdate": "^3.0.2", 81 | "pkg": "^4.4.9", 82 | "prettier": "^2.2.1", 83 | "pretty-quick": "^3.1.0", 84 | "rimraf": "^3.0.2", 85 | "semantic-release": "^17.3.0", 86 | "ts-jest": "^26.4.4", 87 | "ts-node": "^9.1.1", 88 | "typedoc": "^0.20.6", 89 | "typescript": "^4.1.3" 90 | }, 91 | "pkg": { 92 | "assets": "node_modules/puppeteer-extra-plugin-stealth/**/*.*" 93 | }, 94 | "husky": { 95 | "hooks": { 96 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", 97 | "pre-commit": "pretty-quick --staged" 98 | } 99 | }, 100 | "release": { 101 | "plugins": [ 102 | [ 103 | "@semantic-release/commit-analyzer", 104 | { 105 | "preset": "angular", 106 | "releaseRules": "./semantic-release-rules.js" 107 | } 108 | ], 109 | "@semantic-release/release-notes-generator", 110 | "@semantic-release/changelog", 111 | "@semantic-release/npm", 112 | "@semantic-release/git", 113 | [ 114 | "@semantic-release/github", 115 | { 116 | "assets": [ 117 | { 118 | "label": "Linux (tar.gz)", 119 | "name": "amazon-ynab-sync-linux-${nextRelease.version}.tar.gz", 120 | "path": "pkg/amazon-ynab-sync-linux.tar.gz" 121 | }, 122 | { 123 | "label": "Linux (zip)", 124 | "name": "amazon-ynab-sync-linux-${nextRelease.version}.zip", 125 | "path": "pkg/amazon-ynab-sync-linux.zip" 126 | }, 127 | { 128 | "label": "macOS (tar.gz)", 129 | "name": "amazon-ynab-sync-macos-${nextRelease.version}.tar.gz", 130 | "path": "pkg/amazon-ynab-sync-macos.tar.gz" 131 | }, 132 | { 133 | "label": "macOS (zip)", 134 | "name": "amazon-ynab-sync-macos-${nextRelease.version}.zip", 135 | "path": "pkg/amazon-ynab-sync-macos.zip" 136 | }, 137 | { 138 | "label": "Windows (tar.gz)", 139 | "name": "amazon-ynab-sync-win-${nextRelease.version}.tar.gz", 140 | "path": "pkg/amazon-ynab-sync-win.tar.gz" 141 | }, 142 | { 143 | "label": "Windows (zip)", 144 | "name": "amazon-ynab-sync-win-${nextRelease.version}.zip", 145 | "path": "pkg/amazon-ynab-sync-win.zip" 146 | } 147 | ] 148 | } 149 | ] 150 | ] 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/ynab.test.ts: -------------------------------------------------------------------------------- 1 | import mockdate from 'mockdate'; 2 | import { mocked } from 'ts-jest/utils'; 3 | import { readCache, writeCache } from './cache'; 4 | import { addTransactions, getAccountId, getBudgetId } from './ynab'; 5 | import { mocks as ynabMocks, SaveTransaction } from './__mocks__/ynab-client'; 6 | 7 | jest.mock('./cache'); 8 | jest.mock('./config'); 9 | 10 | describe('account', () => { 11 | beforeEach(() => { 12 | jest.clearAllMocks(); 13 | mockdate.set('2021-01-31'); 14 | }); 15 | 16 | afterEach(() => { 17 | mockdate.reset(); 18 | }); 19 | 20 | describe('getBudgetId', () => { 21 | it('should use cached ID if one exists', async () => { 22 | const budgetId = '7400b90c-9324-4a36-b754-7f06c8e53eef'; 23 | const mock = mocked(readCache).mockResolvedValueOnce(budgetId); 24 | 25 | const result = await getBudgetId('my budget'); 26 | expect(mock).toBeCalledWith(['f82918ba-4aa7-4805-b9be-fe5e87eaacf3', 'my budget'], 3600000); 27 | expect(result).toEqual(budgetId); 28 | }); 29 | 30 | it('should get ID from API if no cached value exists', async () => { 31 | const budgetId = 'f4cc821a-d79a-4c45-86d8-3c477352cbdd'; 32 | 33 | ynabMocks.budgets.getBudgets.mockResolvedValueOnce({ 34 | data: { 35 | budgets: [ 36 | { 37 | id: budgetId, 38 | name: 'my budget', 39 | }, 40 | ], 41 | }, 42 | }); 43 | 44 | const result = await getBudgetId('my budget'); 45 | expect(mocked(writeCache)).toBeCalledWith( 46 | ['f82918ba-4aa7-4805-b9be-fe5e87eaacf3', 'my budget'], 47 | budgetId, 48 | ); 49 | expect(result).toEqual(budgetId); 50 | }); 51 | 52 | it('throw error if budget does not exist', async () => { 53 | expect(getAccountId('my budget', 'amazon.com account')).rejects.toThrow( 54 | 'Unable to find budget', 55 | ); 56 | }); 57 | }); 58 | 59 | describe('getAccountId', () => { 60 | it('should use cached ID if one exists', async () => { 61 | const accountId = '42ed9b93-4f94-4164-ac88-72eb54d818ed'; 62 | const mock = mocked(readCache).mockResolvedValueOnce(accountId); 63 | 64 | const result = await getAccountId('my budget', 'amazon.com account'); 65 | expect(mock).toBeCalledWith( 66 | ['f82918ba-4aa7-4805-b9be-fe5e87eaacf3', 'my budget', 'amazon.com account'], 67 | 3600000, 68 | ); 69 | expect(result).toEqual(accountId); 70 | }); 71 | 72 | it('should get ID from API if no cached value exists', async () => { 73 | const budgetId = 'f4cc821a-d79a-4c45-86d8-3c477352cbdd'; 74 | const accountId = '42ed9b93-4f94-4164-ac88-72eb54d818ed'; 75 | 76 | ynabMocks.budgets.getBudgets.mockResolvedValueOnce({ 77 | data: { 78 | budgets: [ 79 | { 80 | id: budgetId, 81 | name: 'my budget', 82 | }, 83 | ], 84 | }, 85 | }); 86 | 87 | ynabMocks.accounts.getAccounts.mockResolvedValueOnce({ 88 | data: { 89 | accounts: [ 90 | { 91 | id: accountId, 92 | name: 'amazon.com account', 93 | }, 94 | ], 95 | }, 96 | }); 97 | 98 | const result = await getAccountId('my budget', 'amazon.com account'); 99 | expect(ynabMocks.accounts.getAccounts).toBeCalledWith(budgetId); 100 | expect(mocked(writeCache)).toBeCalledWith( 101 | ['f82918ba-4aa7-4805-b9be-fe5e87eaacf3', 'my budget', 'amazon.com account'], 102 | accountId, 103 | ); 104 | expect(result).toEqual(accountId); 105 | }); 106 | 107 | it('throw error if account does not exist', async () => { 108 | const budgetId = 'f4cc821a-d79a-4c45-86d8-3c477352cbdd'; 109 | 110 | ynabMocks.budgets.getBudgets.mockResolvedValueOnce({ 111 | data: { 112 | budgets: [ 113 | { 114 | id: budgetId, 115 | name: 'my budget', 116 | }, 117 | ], 118 | }, 119 | }); 120 | 121 | expect(getAccountId('my budget', 'amazon.com account')).rejects.toThrow( 122 | 'Unable to find account', 123 | ); 124 | }); 125 | }); 126 | 127 | describe('addTransactions', () => { 128 | it('should send transactions to YNAB', async () => { 129 | const budgetId = 'f4cc821a-d79a-4c45-86d8-3c477352cbdd'; 130 | const accountId = '42ed9b93-4f94-4164-ac88-72eb54d818ed'; 131 | 132 | ynabMocks.budgets.getBudgets 133 | .mockResolvedValueOnce({ 134 | data: { 135 | budgets: [ 136 | { 137 | id: budgetId, 138 | name: 'Budget', 139 | }, 140 | ], 141 | }, 142 | }) 143 | .mockResolvedValueOnce({ 144 | data: { 145 | budgets: [ 146 | { 147 | id: budgetId, 148 | name: 'Budget', 149 | }, 150 | ], 151 | }, 152 | }); 153 | 154 | ynabMocks.accounts.getAccounts.mockResolvedValueOnce({ 155 | data: { 156 | accounts: [ 157 | { 158 | id: accountId, 159 | name: 'Amazon.com', 160 | }, 161 | ], 162 | }, 163 | }); 164 | 165 | await addTransactions([ 166 | { 167 | amount: 12345, 168 | cleared: SaveTransaction.ClearedEnum.Cleared, 169 | date: '2020-12-31', 170 | import_id: 'b10500b5a731520b3fd242589d6cc0bc4fc1', 171 | memo: 'Very small rocks', 172 | payee_name: 'Amazon.com', 173 | }, 174 | ]); 175 | 176 | expect(ynabMocks.transactions.createTransactions).toBeCalledWith( 177 | 'f4cc821a-d79a-4c45-86d8-3c477352cbdd', 178 | { 179 | transactions: [ 180 | { 181 | account_id: '42ed9b93-4f94-4164-ac88-72eb54d818ed', 182 | amount: 12345, 183 | cleared: 'cleared', 184 | date: '2020-12-31', 185 | import_id: 'b10500b5a731520b3fd242589d6cc0bc4fc1', 186 | memo: 'Very small rocks', 187 | payee_name: 'Amazon.com', 188 | }, 189 | ], 190 | }, 191 | ); 192 | }); 193 | }); 194 | }); 195 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [2.0.0](https://github.com/starsprung/amazon-ynab-sync/compare/v1.5.4...v2.0.0) (2022-08-08) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * use refund date for refund transactions ([d083789](https://github.com/starsprung/amazon-ynab-sync/commit/d083789706e3548d460ba30c3d8102a1f1a9ec5e)) 7 | 8 | 9 | ### BREAKING CHANGES 10 | 11 | * refund transaction hashes changed 12 | 13 | ## [1.5.4](https://github.com/starsprung/amazon-ynab-sync/compare/v1.5.3...v1.5.4) (2022-08-08) 14 | 15 | 16 | ### Bug Fixes 17 | 18 | * update amazon-order-reports-api ([010fc96](https://github.com/starsprung/amazon-ynab-sync/commit/010fc9650adad46b45b8e20dfb88f4eaf9133e7b)) 19 | 20 | ## [1.5.3](https://github.com/starsprung/amazon-ynab-sync/compare/v1.5.2...v1.5.3) (2021-11-10) 21 | 22 | 23 | ### Bug Fixes 24 | 25 | * correct refund totals to include tax ([9288bc0](https://github.com/starsprung/amazon-ynab-sync/commit/9288bc098416f9bafc24835618d323e64f25924d)) 26 | 27 | ## [1.5.2](https://github.com/starsprung/amazon-ynab-sync/compare/v1.5.1...v1.5.2) (2021-01-14) 28 | 29 | 30 | ### Bug Fixes 31 | 32 | * fix lodash.get dependency ([46a90b6](https://github.com/starsprung/amazon-ynab-sync/commit/46a90b64a0531a0aeae725872cd9acba3536b5fb)) 33 | 34 | ## [1.5.1](https://github.com/starsprung/amazon-ynab-sync/compare/v1.5.0...v1.5.1) (2021-01-10) 35 | 36 | # [1.5.0](https://github.com/starsprung/amazon-ynab-sync/compare/v1.4.8...v1.5.0) (2021-01-09) 37 | 38 | 39 | ### Features 40 | 41 | * add --payee option ([86da1a6](https://github.com/starsprung/amazon-ynab-sync/commit/86da1a68b4994bd389d82d0f43238096af975f78)), closes [#21](https://github.com/starsprung/amazon-ynab-sync/issues/21) 42 | 43 | ## [1.4.8](https://github.com/starsprung/amazon-ynab-sync/compare/v1.4.7...v1.4.8) (2021-01-09) 44 | 45 | 46 | ### Bug Fixes 47 | 48 | * **amazon.ts:** account for missing miscellaneous ajustments (e.g. gift wrapping) ([6d7dba4](https://github.com/starsprung/amazon-ynab-sync/commit/6d7dba4bc0f705ed209a8ae833e3b1f56dfa320e)), closes [#20](https://github.com/starsprung/amazon-ynab-sync/issues/20) 49 | 50 | ## [1.4.7](https://github.com/starsprung/amazon-ynab-sync/compare/v1.4.6...v1.4.7) (2021-01-06) 51 | 52 | 53 | ### Bug Fixes 54 | 55 | * add email address to support notice ([f979f1d](https://github.com/starsprung/amazon-ynab-sync/commit/f979f1d8382117c134294a4885988d2051dc0246)) 56 | 57 | ## [1.4.6](https://github.com/starsprung/amazon-ynab-sync/compare/v1.4.5...v1.4.6) (2021-01-06) 58 | 59 | ## [1.4.5](https://github.com/starsprung/amazon-ynab-sync/compare/v1.4.4...v1.4.5) (2021-01-06) 60 | 61 | ## [1.4.4](https://github.com/starsprung/amazon-ynab-sync/compare/v1.4.3...v1.4.4) (2021-01-06) 62 | 63 | 64 | ### Bug Fixes 65 | 66 | * reflect promotions on an order as a separate transaction ([c19c1e8](https://github.com/starsprung/amazon-ynab-sync/commit/c19c1e8c848e785c4c832b09237e57417ff5bc9f)), closes [#11](https://github.com/starsprung/amazon-ynab-sync/issues/11) 67 | 68 | ## [1.4.3](https://github.com/starsprung/amazon-ynab-sync/compare/v1.4.2...v1.4.3) (2021-01-05) 69 | 70 | 71 | ### Bug Fixes 72 | 73 | * fix error when report has no data ([7848e92](https://github.com/starsprung/amazon-ynab-sync/commit/7848e927a625151161d745aa5207ffb746ffb33d)), closes [#13](https://github.com/starsprung/amazon-ynab-sync/issues/13) 74 | 75 | ## [1.4.2](https://github.com/starsprung/amazon-ynab-sync/compare/v1.4.1...v1.4.2) (2021-01-05) 76 | 77 | ## [1.4.1](https://github.com/starsprung/amazon-ynab-sync/compare/v1.4.0...v1.4.1) (2021-01-05) 78 | 79 | # [1.4.0](https://github.com/starsprung/amazon-ynab-sync/compare/v1.3.4...v1.4.0) (2021-01-04) 80 | 81 | 82 | ### Features 83 | 84 | * import shipping charges ([84a28ec](https://github.com/starsprung/amazon-ynab-sync/commit/84a28ec9c0b2bf0a6c91256c7f5479e8349da7ab)) 85 | 86 | ## [1.3.4](https://github.com/starsprung/amazon-ynab-sync/compare/v1.3.3...v1.3.4) (2021-01-04) 87 | 88 | ## [1.3.3](https://github.com/starsprung/amazon-ynab-sync/compare/v1.3.2...v1.3.3) (2021-01-04) 89 | 90 | ## [1.3.2](https://github.com/starsprung/amazon-ynab-sync/compare/v1.3.1...v1.3.2) (2021-01-04) 91 | 92 | 93 | ### Bug Fixes 94 | 95 | * fix release process ([f806311](https://github.com/starsprung/amazon-ynab-sync/commit/f80631142a5dfd7cd1b128bf6ec981b74d770c8c)) 96 | 97 | ## [1.3.1](https://github.com/starsprung/amazon-ynab-sync/compare/v1.3.0...v1.3.1) (2021-01-04) 98 | 99 | 100 | ### Bug Fixes 101 | 102 | * fix pre-built binaries ([9eec42a](https://github.com/starsprung/amazon-ynab-sync/commit/9eec42ae350343196c4bb03dfa082ef668a9f891)) 103 | 104 | ## [1.3.1](https://github.com/starsprung/amazon-ynab-sync/compare/v1.3.0...v1.3.1) (2021-01-03) 105 | 106 | 107 | ### Bug Fixes 108 | 109 | * fix pre-built binaries ([9eec42a](https://github.com/starsprung/amazon-ynab-sync/commit/9eec42ae350343196c4bb03dfa082ef668a9f891)) 110 | 111 | # [1.3.0](https://github.com/starsprung/amazon-ynab-sync/compare/v1.2.0...v1.3.0) (2021-01-03) 112 | 113 | 114 | ### Features 115 | 116 | * support external Chromium binary ([f7115ff](https://github.com/starsprung/amazon-ynab-sync/commit/f7115ff289bffb29fdb65defe34b733cccf8847b)) 117 | 118 | # [1.2.0](https://github.com/starsprung/amazon-ynab-sync/compare/v1.1.0...v1.2.0) (2021-01-03) 119 | 120 | 121 | ### Features 122 | 123 | * run on node 14 ([#7](https://github.com/starsprung/amazon-ynab-sync/issues/7)) ([9a8522d](https://github.com/starsprung/amazon-ynab-sync/commit/9a8522de604901424a9a8dacb6b67901085dd038)) 124 | 125 | # [1.1.0](https://github.com/starsprung/amazon-ynab-sync/compare/v1.0.2...v1.1.0) (2021-01-03) 126 | 127 | 128 | ### Features 129 | 130 | * add --cleared option ([59d2615](https://github.com/starsprung/amazon-ynab-sync/commit/59d2615d668b9786d689e209b18ff2419305ea20)) 131 | 132 | ## [1.0.2](https://github.com/starsprung/amazon-ynab-sync/compare/v1.0.1...v1.0.2) (2021-01-03) 133 | 134 | 135 | ### Bug Fixes 136 | 137 | * remove app-root-path for version ([ac42e4a](https://github.com/starsprung/amazon-ynab-sync/commit/ac42e4a9fc89e28999887809fb1f624ec124c141)) 138 | 139 | ## [1.0.1](https://github.com/starsprung/amazon-ynab-sync/compare/v1.0.0...v1.0.1) (2021-01-03) 140 | 141 | 142 | ### Bug Fixes 143 | 144 | * fix app-root-path-dependency ([17527f8](https://github.com/starsprung/amazon-ynab-sync/commit/17527f8246de80c429c98f0a16564cd9c0460807)) 145 | 146 | # 1.0.0 (2021-01-03) 147 | 148 | 149 | ### Features 150 | 151 | * cli improvements ([5b6aaef](https://github.com/starsprung/amazon-ynab-sync/commit/5b6aaef2230e9f33cd6fc494eaca98d7cc3697b2)) 152 | -------------------------------------------------------------------------------- /src/amazon.ts: -------------------------------------------------------------------------------- 1 | import { AmazonOrderReportsApi, Cookie, LogLevel } from 'amazon-order-reports-api'; 2 | import hasha from 'hasha'; 3 | import inquirer from 'inquirer'; 4 | import { SaveTransaction } from 'ynab-client'; 5 | import { readCache, writeCache } from './cache'; 6 | import { getConfig } from './config'; 7 | import { getPuppeteerExecutable } from './puppeteer'; 8 | import get from 'lodash.get'; 9 | import logger from './logger'; 10 | 11 | const config = getConfig(); 12 | 13 | export type AmazonTransaction = Omit; 14 | type IdRecord = Record; 15 | type Totals = Record; 16 | 17 | const prompt = async (message: string, type: 'input' | 'password' = 'input') => 18 | (await inquirer.prompt([{ name: 'p', message, type }]))?.p; 19 | 20 | const getUsername = async (): Promise => 21 | config.amazonUsername ?? (await prompt('Amazon Username:')); 22 | const getPassword = async (): Promise => 23 | config.amazonPassword ?? (await prompt('Amazon Password:', 'password')); 24 | const getOtpCode = async (): Promise => 25 | config.amazonOtpCode ?? (await prompt('Amazon OTP Code:')); 26 | 27 | const generateImportId = ( 28 | idInputs: Array, 29 | seenIds: IdRecord, 30 | ): string => { 31 | const baseHash = hasha( 32 | idInputs.map((v) => `${v instanceof Date ? v.toISOString() : v}`), 33 | { algorithm: 'sha256' }, 34 | ); 35 | 36 | const occurence = seenIds[baseHash] ?? 0; 37 | seenIds[baseHash] = occurence + 1; 38 | 39 | return hasha([baseHash, `${occurence}`], { algorithm: 'sha256' }).substr(0, 36); 40 | }; 41 | 42 | const createTransation = ( 43 | seenIds: IdRecord, 44 | type: 'item' | 'refund' | 'shipping' | 'promotion' | 'misc', 45 | { 46 | orderId, 47 | date, 48 | asinIsbn, 49 | title, 50 | seller, 51 | quantity, 52 | amount, 53 | }: { 54 | orderId: string; 55 | date: Date; 56 | asinIsbn?: string; 57 | title?: string; 58 | seller?: string; 59 | quantity?: number; 60 | amount: number; 61 | }, 62 | ): AmazonTransaction => { 63 | const t = { 64 | amount: Math.round(amount * 1000), 65 | cleared: config.cleared 66 | ? SaveTransaction.ClearedEnum.Cleared 67 | : SaveTransaction.ClearedEnum.Uncleared, 68 | date: `${date.toISOString().split('T')[0]}`, 69 | import_id: generateImportId( 70 | [type, orderId, date, asinIsbn, title, seller, quantity, amount], 71 | seenIds, 72 | ), 73 | memo: 74 | type === 'shipping' 75 | ? `Shipping for ${orderId}` 76 | : type === 'promotion' 77 | ? `Promotion for ${orderId}` 78 | : type === 'misc' 79 | ? `Misc adjustment for ${orderId}` 80 | : title 81 | ? `${(quantity ?? 0) > 1 ? `${quantity} x ` : ''}${title}`.substr(0, 200) 82 | : '', 83 | payee_name: config.payee ?? seller ?? 'Amazon.com', 84 | }; 85 | 86 | logger.silly('Transaction', t); 87 | 88 | return t; 89 | }; 90 | 91 | export const getAmazonTransactions = async function* (): AsyncGenerator { 92 | const seenIds: IdRecord = {}; 93 | 94 | const api = new AmazonOrderReportsApi({ 95 | username: getUsername, 96 | password: getPassword, 97 | otpSecret: config.amazonOtpSecret, 98 | otpFn: getOtpCode, 99 | logLevel: config.logLevel as LogLevel, 100 | cookies: ((await readCache('cookies')) as Array) ?? [], 101 | saveCookiesFn: (cookies: Array) => writeCache('cookies', cookies), 102 | puppeteerOpts: { 103 | executablePath: await getPuppeteerExecutable(), 104 | ...(config.debugMode ? { headless: false, slowMo: 100 } : {}), 105 | }, 106 | }); 107 | 108 | const itemSubtotals: Totals = {}; 109 | const itemTotals: Totals = {}; 110 | const shipmentSubtotals: Totals = {}; 111 | const shipmentPromotionTotals: Totals = {}; 112 | const shipmentShippingTotals: Totals = {}; 113 | const shipmentTotals: Totals = {}; 114 | const orderDates: Record = {}; 115 | 116 | try { 117 | for await (const item of api.getItems({ 118 | startDate: new Date(config.startDate), 119 | endDate: new Date(), 120 | })) { 121 | if (item.itemTotal) { 122 | yield createTransation(seenIds, 'item', { 123 | orderId: item.orderId, 124 | date: item.orderDate, 125 | asinIsbn: item.asinIsbn, 126 | title: item.title, 127 | seller: item.seller, 128 | quantity: item.quantity, 129 | amount: -item.itemTotal, 130 | }); 131 | } 132 | 133 | itemSubtotals[item.orderId] = 134 | get(itemSubtotals, item.orderId, 0) + get(item, 'itemSubtotal', 0); 135 | itemTotals[item.orderId] = get(itemTotals, item.orderId, 0) + get(item, 'itemTotal', 0); 136 | } 137 | 138 | for await (const item of api.getRefunds({ 139 | startDate: new Date(config.startDate), 140 | endDate: new Date(), 141 | })) { 142 | if (item.refundAmount) { 143 | yield createTransation(seenIds, 'refund', { 144 | orderId: item.orderId, 145 | date: item.refundDate, 146 | asinIsbn: item.asinIsbn, 147 | title: item.title, 148 | seller: item.seller, 149 | quantity: item.quantity, 150 | amount: item.refundAmount + (item.refundTaxAmount ?? 0), 151 | }); 152 | } 153 | } 154 | 155 | for await (const item of api.getShipments({ 156 | startDate: new Date(config.startDate), 157 | endDate: new Date(), 158 | })) { 159 | if (item.shippingCharge) { 160 | yield createTransation(seenIds, 'shipping', { 161 | orderId: item.orderId, 162 | date: item.orderDate, 163 | amount: -item.shippingCharge, 164 | }); 165 | 166 | shipmentShippingTotals[item.orderId] = 167 | get(shipmentShippingTotals, item.orderId, 0) + get(item, 'shippingCharge', 0); 168 | } 169 | 170 | if (item.totalPromotions) { 171 | yield createTransation(seenIds, 'promotion', { 172 | orderId: item.orderId, 173 | date: item.orderDate, 174 | amount: item.totalPromotions, 175 | }); 176 | 177 | shipmentPromotionTotals[item.orderId] = 178 | get(shipmentPromotionTotals, item.orderId, 0) + get(item, 'totalPromotions', 0); 179 | } 180 | 181 | shipmentSubtotals[item.orderId] = 182 | get(shipmentSubtotals, item.orderId, 0) + get(item, 'subtotal', 0); 183 | shipmentTotals[item.orderId] = 184 | get(shipmentTotals, item.orderId, 0) + get(item, 'totalCharged', 0); 185 | orderDates[item.orderId] = item.orderDate; 186 | } 187 | 188 | // Find adjustments not accounted for by shipping/promotions (giftwrap, etc) 189 | for (const [orderId, itemSubtotal] of Object.entries(itemSubtotals)) { 190 | const shipmentSubtotal = shipmentSubtotals[orderId]; 191 | 192 | // if subtotals are significantly different, skip it as this order has likely 193 | // not finished shipping 194 | if (Math.abs(shipmentSubtotal - itemSubtotal) > 0.01) { 195 | continue; 196 | } 197 | 198 | const itemTotal = get(itemTotals, orderId, 0); 199 | const shipmentPromotionTotal = get(shipmentPromotionTotals, orderId, 0); 200 | const shipmentShippingTotal = get(shipmentShippingTotals, orderId, 0); 201 | const shipmentTotal = get(shipmentTotals, orderId, 0); 202 | const orderDate = orderDates[orderId]; 203 | 204 | const unaccountedDifference = 205 | shipmentTotal - (itemTotal + shipmentShippingTotal - shipmentPromotionTotal); 206 | 207 | if (unaccountedDifference > 0.01) { 208 | yield createTransation(seenIds, 'misc', { 209 | orderId, 210 | date: orderDate, 211 | amount: -unaccountedDifference, 212 | }); 213 | } 214 | } 215 | } finally { 216 | if (config.debugMode) { 217 | await new Promise((resolve) => setTimeout(resolve, 10000)); 218 | } 219 | 220 | await api.stop(); 221 | } 222 | }; 223 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # amazon-ynab-sync 2 | 3 | Automatically syncs Amazon.com transactions to [YNAB](https://www.youneedabudget.com/). 4 | 5 | The basic concept is to have a dedicated account within YNAB for Amazon transations. Any bank or credit card payments to Amazon can 6 | then be set as transfers to this Amazon account. This makes categorizing Amazon spending much easier, as every individual item is its 7 | own transaction. 8 | 9 | Internally, this runs a headless instance of [Chromium](https://www.chromium.org/) controlled by [Puppeteer](https://github.com/puppeteer/puppeteer) 10 | to login to your Amazon account, retrieve your transations and submits them to YNAB using the YNAB API. This utilizes Amazon's Order History Reports 11 | functionality, which generates e-mails from Amazon, [see below](#email-notifications) a suggestion for handling these. 12 | 13 | ## Support this project 14 | 15 | If you'd like to support this project, please consider donating to the [Oregon Food Bank](https://oregonfoodbank.org/donate). Donate more than $50 and [send me a screenshot of your donation](mailto:s@starsprung.com) and I'll add you to a [list of supporters](SUPPORTERS.md)! 16 | 17 | ## Pre-requisites 18 | 19 | - [Your YNAB personal access token](https://api.youneedabudget.com/#personal-access-tokens) 20 | - Your Amazon.com login credentials 21 | - On Linux, some Puppeteer dependencies [may need to be manually installed](#troubleshooting) 22 | 23 | ## Installation 24 | 25 | ### As a pre-built binary 26 | 27 | Pre-built binaries for Linux, macOS and Windows can be downloaded from the 28 | [releases page](https://github.com/starsprung/amazon-ynab-sync/releases/). Simply extract 29 | them and run the binary on the command line. 30 | 31 | These binaries are packaged with Node.js, so there is no need to download that separately. 32 | They do not include Chromium, but that will be downloaded automatically when the application 33 | is run for the first time. 34 | 35 | ### As an NPM package 36 | 37 | #### Pre-requisites 38 | 39 | - [Node.js](https://nodejs.org/) >= 14.4.0 40 | - npm >= 6.9.0 41 | 42 | #### Install 43 | 44 | ``` 45 | npm install -g amazon-ynab-sync 46 | ``` 47 | 48 | On some systems if you're installing to a privileged location you may need: 49 | 50 | ``` 51 | sudo npm install -g amazon-ynab-sync --unsafe-perm=true 52 | ``` 53 | 54 | Ensure you have the Node.js `bin` directory in your environment path. 55 | 56 | ## Usage 57 | 58 | Ensure you have created a budget and an unlinked account in YNAB in which you want to record your Amazon transactions 59 | (I generally use a Cash account). In these examples I'll use the following as samples values: 60 | 61 | | Option | Value | 62 | | ---------------------------- | ---------------------------------------------------------------- | 63 | | Amazon username | test@example.com | 64 | | Amazon password | password123 | 65 | | YNAB access token | 437e0a95e9ce155e5deae9d105305988cac9f4664f480650cc18d3327cae36ec | 66 | | YNAB budget name | My Budget | 67 | | YNAB Amazon.com account name | Amazon.com | 68 | 69 | ### Basic usage 70 | 71 | ``` 72 | amazon-ynab-sync \ 73 | --ynab-access-token 437e0a95e9ce155e5dea5b62b5305988cac9f4664f480650cc18d3327cae36ec \ 74 | --ynab-budget-name "My Budget" \ 75 | --ynab-account-name "Amazon.com" \ 76 | --log-level none 77 | 78 | ? Amazon Username: test@example.com 79 | ? Amazon Password: [hidden] 80 | ? Amazon OTP Code: 123456 81 | ``` 82 | 83 | After the initial login the application will store the cookies provided by Amazon and will only re-prompt for credentials 84 | when the Amazon.com login session expires. 85 | 86 | ### Providing login credentials non-interactively 87 | 88 | It's possible to provide your Amazon.com credentials as parameters via CLI options or environment variables. In general 89 | it's not secure to provide your password directly in clear text, as they may be logged in your shell history, but many password 90 | managers such as [LastPass](https://github.com/lastpass/lastpass-cli) or [1Password](https://1password.com/downloads/command-line/) 91 | have a CLI tool that can be used to provide the login credentials. You could also save your YNAB personal access token in a password 92 | manager to improve security. 93 | 94 | #### LastPass 95 | 96 | ``` 97 | AMAZON_USERNAME="$(lpass show 'amazon.com' -u)" \ 98 | AMAZON_PASSWORD="$(lpass show 'amazon.com' -p)" \ 99 | amazon-ynab-sync 100 | ``` 101 | 102 | #### 1Password 103 | 104 | ``` 105 | AMAZON_USERNAME="$(op get item amazon.com --fields username)" \ 106 | AMAZON_PASSWORD="$(op get item amazon.com --fields password)" \ 107 | amazon-ynab-sync 108 | ``` 109 | 110 | #### macOS Keychain 111 | 112 | ``` 113 | AMAZON_USERNAME="$(security find-generic-password -s amazon.com | grep acct | sed -E 's/^.*"acct"\="(.*)".*$/\1/')" \ 114 | AMAZON_PASSWORD="$(security find-generic-password -s 'amazon.com' -w)" \ 115 | amazon-ynab-sync 116 | ``` 117 | 118 | ### Providing options in a config file 119 | 120 | Options can also be saved in a config file. The location of this file is platform-dependent: 121 | 122 | | Plaform | Location | 123 | | ------- | --------------------------------------------------------------------------------------------------- | 124 | | Linux | $XDG_CONFIG_HOME/amazon-ynab-sync/config.json
or
~/.config/amazon-ynab-sync/config.json | 125 | | macOS | ~/Library/Preferences/amazon-ynab-sync/config.json | 126 | | Windows | %AppData%\amazon-ynab-sync\Config\config.json | 127 | 128 | #### Example config.json 129 | 130 | ``` 131 | { 132 | "amazonUsername": "test@example.com", 133 | "ynabAccessToken": "437e0a95e9ce155e5dea5b62b5305988cac9f4664f480650cc18d3327cae36ec", 134 | "ynabAccountName": "Amazon.com", 135 | "ynabBudgetName": "My Budget" 136 | } 137 | ``` 138 | 139 | ## Options 140 | 141 | | Command-line option | Environment Variable | Config file | Description | Default | 142 | | ------------------- | -------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 143 | | --amazon-otp-code | AMAZON_OTP_CODE | amazonOtpCode | Amazon OTP/2SV code | | 144 | | --amazon-otp-secret | AMAZON_OTP_SECRET | amazonOtpSecret | Amazon OTP/2SV secret. This is the code you get during the Authenticator App setup on the Amazon 2SV Settings page. If this option is used, care should be taken to store this securely. An insecurely stored OTP secret is the same as not having OTP at all | | 145 | | --amazon-password | AMAZON_PASSWORD | amazonPassword | Amazon password | | 146 | | --amazon-username | AMAZON_USERNAME | amazonUsername | Amazon username | | 147 | | --cache-dir | CACHE_DIR | cacheDir | Directory to use for caching API responses and cookies | Linux: $XDG_CACHE_HOME/amazon-ynab-sync
or
~/.cache/amazon-ynab-sync

macOS: ~/Library/Caches/amazon-ynab-sync

Windows: %LocalAppData%\amazon-ynab-sync\Cache | 148 | | --config-dir | CONFIG_DIR | configDir | Directory to look for config file | Linux: $XDG_CONFIG_HOME/amazon-ynab-sync
or
~/.config/amazon-ynab-sync

macOS: ~/Library/Preferences/amazon-ynab-sync/

Windows: %AppData%\amazon-ynab-sync\Config | 149 | | --cleared | CLEARED | cleared | Whether transactions should be added as cleared by default | true | 150 | | --debug-mode | DEBUG_MODE | debugMode | Run the internal browser in visible/slo-mo mode | false | 151 | | --log-level | LOG_LEVEL | logLevel | Level of logs to output. Possible values: "debug", "info", "error", "none", "silly" | info | 152 | | --payee | PAYEE | payee | Override the "Payee" field in YNAB with this value. If unset it will default to the seller name or Amazon.com | | 153 | | --start-date | START_DATE | startDate | Only sync transactions which appear after this date. | 30 days ago | 154 | | --ynab-access-token | YNAB_ACCESS_TOKEN | ynabAccessToken | [YNAB personal access token](https://api.youneedabudget.com/#personal-access-tokens) | | 155 | | --ynab-account-name | YNAB_ACCOUNT_NAME | ynabAccountName | Name of YNAB account in which you wish to record Amazon transactions | | 156 | | --ynab-budget-name | YNAB_BUDGET_NAME | ynabBudgetName | Name of the YNAB budget containing the above account | | 157 | 158 | ## Email notifications 159 | 160 | As a side effect of generating an order report, Amazon will send an email notification that the order report is ready. 161 | This can generate a large volume of emails if reports are retrieved frequently. In many mail providers, an e-mail filter 162 | can be used to delete or move these emails. E.g. in Gmail: 163 | 164 | ``` 165 | from:(no-reply@amazon.com) subject:(Your order history report) 166 | ``` 167 | 168 | ## Troubleshooting 169 | 170 | In some cases on Linux you may need to install some additional Puppeteer dependencies manually. If you get error messages regarding 171 | failure to launch the browser process, see the 172 | [Puppeteer troubleshooting section](https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md#chrome-headless-doesnt-launch-on-unix). 173 | 174 | If you get failed sign-ins or other errors, you might try running with `--log-level silly --debug-mode` to get a better idea of what's happening. 175 | -------------------------------------------------------------------------------- /src/amazon.test.ts: -------------------------------------------------------------------------------- 1 | import { OrderItem, Refund, Shipment } from 'amazon-order-reports-api'; 2 | import inquirer from 'inquirer'; 3 | import mockdate from 'mockdate'; 4 | import { mocked } from 'ts-jest/utils'; 5 | import { AmazonTransaction, getAmazonTransactions } from './amazon'; 6 | import { readCache, writeCache } from './cache'; 7 | import { getConfig } from './config'; 8 | import { 9 | AmazonOrderReportsApi, 10 | mocks as amazonOrderReportsApiMocks, 11 | } from './__mocks__/amazon-order-reports-api'; 12 | 13 | jest.mock('inquirer'); 14 | jest.mock('./cache'); 15 | jest.mock('./config'); 16 | 17 | describe('amazon', () => { 18 | beforeEach(() => { 19 | jest.clearAllMocks(); 20 | mockdate.set('2021-01-31'); 21 | }); 22 | 23 | afterEach(() => { 24 | mockdate.reset(); 25 | }); 26 | 27 | describe('getAmazonTransactions', () => { 28 | beforeEach(() => { 29 | mocked(amazonOrderReportsApiMocks).getItems.mockImplementationOnce(async function* () { 30 | yield* [ 31 | { 32 | asinIsbn: 'B06Y5XG33Q', 33 | buyerName: 'Test Man', 34 | carrierNameTrackingNumber: 'USPS(9300120111405739275629)', 35 | category: 'HEALTH_PERSONAL_CARE', 36 | condition: 'new', 37 | currency: 'USD', 38 | itemSubtotal: 21.75, 39 | itemSubtotalTax: 0, 40 | itemTotal: 21.75, 41 | listPricePerUnit: 0, 42 | orderDate: new Date('2020-01-03T08:00:00.000Z'), 43 | orderId: '123-7654321-1234567', 44 | orderStatus: 'Shipped', 45 | orderingCustomerEmail: 'email@example.com', 46 | paymentInstrumentType: 'Visa - 1234', 47 | purchasePricePerUnit: 21.75, 48 | quantity: 1, 49 | seller: 'This Is A Seller', 50 | shipmentDate: new Date('2020-01-06T08:00:00.000Z'), 51 | shippingAddressCity: 'Washington', 52 | shippingAddressName: 'Test Man', 53 | shippingAddressState: 'DC', 54 | shippingAddressStreet1: '1600 Pennsylvania Avenue NW', 55 | shippingAddressZip: '20500', 56 | title: 'Some Product', 57 | unspscCode: '38462846', 58 | website: 'Amazon.com', 59 | } as OrderItem, 60 | ]; 61 | }); 62 | 63 | mocked(amazonOrderReportsApiMocks.getRefunds).mockImplementationOnce(async function* () { 64 | yield* [ 65 | { 66 | asinIsbn: 'B07USNDYEK', 67 | buyerName: 'Test Man', 68 | category: 'COAT', 69 | orderDate: new Date('2020-01-14T08:00:00.000Z'), 70 | orderId: '113-5757575-3939393', 71 | quantity: 2, 72 | refundAmount: 75.98, 73 | refundCondition: 'Completed', 74 | refundDate: new Date('2020-01-25T08:00:00.000Z'), 75 | refundReason: 'Customer Return', 76 | refundTaxAmount: 0, 77 | seller: 'A Seller', 78 | title: 'Some kind of coat', 79 | website: 'Amazon.com', 80 | } as Refund, 81 | { 82 | asinIsbn: 'B07USNDYEK', 83 | buyerName: 'Test Man', 84 | category: 'COAT', 85 | orderDate: new Date('2020-01-14T08:00:00.000Z'), 86 | orderId: '113-5757575-3939393', 87 | quantity: 2, 88 | refundAmount: 75.98, 89 | refundCondition: 'Completed', 90 | refundDate: new Date('2020-01-25T08:00:00.000Z'), 91 | refundReason: 'Customer Return', 92 | refundTaxAmount: 4.2, 93 | seller: 'A Seller', 94 | title: 'Some kind of coat', 95 | website: 'Amazon.com', 96 | } as Refund, 97 | ]; 98 | }); 99 | 100 | mocked(amazonOrderReportsApiMocks).getShipments.mockImplementationOnce(async function* () { 101 | yield* [ 102 | { 103 | buyerName: 'Test Man', 104 | carrierNameTrackingNumber: 'FEDEX(536485026870)', 105 | orderDate: new Date('2020-02-22T08:00:00.000Z'), 106 | orderId: '112-1234569-3333333', 107 | orderStatus: 'Shipped', 108 | orderingCustomerEmail: 'test@example.com', 109 | paymentInstrumentType: 'American Express - 1236', 110 | shipmentDate: new Date('2020-02-23T08:00:00.000Z'), 111 | shippingAddressCity: 'Washington', 112 | shippingAddressName: 'Test Man', 113 | shippingAddressState: 'DC', 114 | shippingAddressStreet1: '1600 Pennsylvania Avenue NW', 115 | shippingAddressZip: '20500', 116 | shippingCharge: 17.99, 117 | subtotal: 489.95, 118 | taxBeforePromotions: 0, 119 | taxCharged: 0, 120 | totalCharged: 507.94, 121 | totalPromotions: 10.51, 122 | website: 'Amazon.com', 123 | } as Shipment, 124 | ]; 125 | }); 126 | }); 127 | 128 | it('should return transactions from amazon', async () => { 129 | const result: Array = []; 130 | for await (const transaction of getAmazonTransactions()) { 131 | result.push(transaction); 132 | } 133 | 134 | expect(result).toMatchSnapshot(); 135 | }); 136 | 137 | it('should handle unaccounted for adjustments', async () => { 138 | mocked(amazonOrderReportsApiMocks) 139 | .getItems.mockReset() 140 | .mockImplementationOnce(async function* () { 141 | yield* [ 142 | { 143 | asinIsbn: 'B06Y5JDY3Q', 144 | buyerName: 'Test Man', 145 | carrierNameTrackingNumber: 'USPS(9300120111405739274857)', 146 | category: 'HEALTH_PERSONAL_CARE', 147 | condition: 'new', 148 | currency: 'USD', 149 | itemSubtotal: 22.75, 150 | itemSubtotalTax: 1, 151 | itemTotal: 23.75, 152 | listPricePerUnit: 0, 153 | orderDate: new Date('2020-01-04T08:00:00.000Z'), 154 | orderId: '123-7654321-1234568', 155 | orderStatus: 'Shipped', 156 | orderingCustomerEmail: 'email@example.com', 157 | paymentInstrumentType: 'Visa - 1234', 158 | purchasePricePerUnit: 22.75, 159 | quantity: 1, 160 | seller: 'This Is A Seller', 161 | shipmentDate: new Date('2020-01-07T08:00:00.000Z'), 162 | shippingAddressCity: 'Washington', 163 | shippingAddressName: 'Test Man', 164 | shippingAddressState: 'DC', 165 | shippingAddressStreet1: '1600 Pennsylvania Avenue NW', 166 | shippingAddressZip: '20500', 167 | title: 'Another Product', 168 | unspscCode: '38462847', 169 | website: 'Amazon.com', 170 | } as OrderItem, 171 | ]; 172 | }); 173 | 174 | mocked(amazonOrderReportsApiMocks.getRefunds).mockReset().mockReturnValueOnce([]); 175 | 176 | mocked(amazonOrderReportsApiMocks) 177 | .getShipments.mockReset() 178 | .mockImplementationOnce(async function* () { 179 | yield* [ 180 | { 181 | buyerName: 'Test Man', 182 | carrierNameTrackingNumber: 'FEDEX(536485026870)', 183 | orderDate: new Date('2020-01-04T08:00:00.000Z'), 184 | orderId: '123-7654321-1234568', 185 | orderStatus: 'Shipped', 186 | orderingCustomerEmail: 'test@example.com', 187 | paymentInstrumentType: 'American Express - 1236', 188 | shipmentDate: new Date('2020-01-07T08:00:00.000Z'), 189 | shippingAddressCity: 'Washington', 190 | shippingAddressName: 'Test Man', 191 | shippingAddressState: 'DC', 192 | shippingAddressStreet1: '1600 Pennsylvania Avenue NW', 193 | shippingAddressZip: '20500', 194 | shippingCharge: 2.5, 195 | subtotal: 22.75, 196 | taxBeforePromotions: 1, 197 | taxCharged: 1, 198 | totalCharged: 27, 199 | totalPromotions: 1, 200 | website: 'Amazon.com', 201 | } as Shipment, 202 | ]; 203 | }); 204 | 205 | const result: Array = []; 206 | for await (const transaction of getAmazonTransactions()) { 207 | result.push(transaction); 208 | } 209 | 210 | expect(result).toMatchSnapshot(); 211 | }); 212 | 213 | it('should not have misc adjustment if order is not complete', async () => { 214 | mocked(amazonOrderReportsApiMocks) 215 | .getItems.mockReset() 216 | .mockImplementationOnce(async function* () { 217 | yield* [ 218 | { 219 | asinIsbn: 'B06Y5JDY3Q', 220 | buyerName: 'Test Man', 221 | carrierNameTrackingNumber: 'USPS(9300120111405739274857)', 222 | category: 'HEALTH_PERSONAL_CARE', 223 | condition: 'new', 224 | currency: 'USD', 225 | itemSubtotal: 22.75, 226 | itemSubtotalTax: 1, 227 | itemTotal: 23.75, 228 | listPricePerUnit: 0, 229 | orderDate: new Date('2020-01-04T08:00:00.000Z'), 230 | orderId: '123-7654321-1234568', 231 | orderStatus: 'Shipped', 232 | orderingCustomerEmail: 'email@example.com', 233 | paymentInstrumentType: 'Visa - 1234', 234 | purchasePricePerUnit: 22.75, 235 | quantity: 1, 236 | seller: 'This Is A Seller', 237 | shipmentDate: new Date('2020-01-07T08:00:00.000Z'), 238 | shippingAddressCity: 'Washington', 239 | shippingAddressName: 'Test Man', 240 | shippingAddressState: 'DC', 241 | shippingAddressStreet1: '1600 Pennsylvania Avenue NW', 242 | shippingAddressZip: '20500', 243 | title: 'Another Product', 244 | unspscCode: '38462847', 245 | website: 'Amazon.com', 246 | } as OrderItem, 247 | { 248 | asinIsbn: 'B06Y5JDY7U', 249 | buyerName: 'Test Man', 250 | carrierNameTrackingNumber: 'USPS(9300120111405739274857)', 251 | category: 'HEALTH_PERSONAL_CARE', 252 | condition: 'new', 253 | currency: 'USD', 254 | itemSubtotal: 25.0, 255 | itemSubtotalTax: 0, 256 | itemTotal: 25.0, 257 | listPricePerUnit: 0, 258 | orderDate: new Date('2020-01-04T08:00:00.000Z'), 259 | orderId: '123-7654321-1234568', 260 | orderStatus: 'Shipped', 261 | orderingCustomerEmail: 'email@example.com', 262 | paymentInstrumentType: 'Visa - 1234', 263 | purchasePricePerUnit: 25, 264 | quantity: 1, 265 | seller: 'This Is A Seller', 266 | shipmentDate: new Date('2020-01-07T08:00:00.000Z'), 267 | shippingAddressCity: 'Washington', 268 | shippingAddressName: 'Test Man', 269 | shippingAddressState: 'DC', 270 | shippingAddressStreet1: '1600 Pennsylvania Avenue NW', 271 | shippingAddressZip: '20500', 272 | title: 'Another Product 2', 273 | unspscCode: '38462847', 274 | website: 'Amazon.com', 275 | } as OrderItem, 276 | ]; 277 | }); 278 | 279 | mocked(amazonOrderReportsApiMocks.getRefunds).mockReset().mockReturnValueOnce([]); 280 | 281 | mocked(amazonOrderReportsApiMocks) 282 | .getShipments.mockReset() 283 | .mockImplementationOnce(async function* () { 284 | yield* [ 285 | { 286 | buyerName: 'Test Man', 287 | carrierNameTrackingNumber: 'FEDEX(536485026870)', 288 | orderDate: new Date('2020-01-04T08:00:00.000Z'), 289 | orderId: '123-7654321-1234568', 290 | orderStatus: 'Shipped', 291 | orderingCustomerEmail: 'test@example.com', 292 | paymentInstrumentType: 'American Express - 1236', 293 | shipmentDate: new Date('2020-01-07T08:00:00.000Z'), 294 | shippingAddressCity: 'Washington', 295 | shippingAddressName: 'Test Man', 296 | shippingAddressState: 'DC', 297 | shippingAddressStreet1: '1600 Pennsylvania Avenue NW', 298 | shippingAddressZip: '20500', 299 | shippingCharge: 0, 300 | subtotal: 22.75, 301 | taxBeforePromotions: 1, 302 | taxCharged: 1, 303 | totalCharged: 27, 304 | totalPromotions: 0, 305 | website: 'Amazon.com', 306 | } as Shipment, 307 | ]; 308 | }); 309 | 310 | const result: Array = []; 311 | for await (const transaction of getAmazonTransactions()) { 312 | result.push(transaction); 313 | } 314 | 315 | expect(result.some((transaction) => transaction.memo?.includes('Misc'))); 316 | }); 317 | 318 | it('should respect cleared configuration', async () => { 319 | const config = getConfig(); 320 | jest.spyOn(config, 'cleared', 'get').mockReturnValueOnce(false); 321 | 322 | const result: Array = []; 323 | for await (const transaction of getAmazonTransactions()) { 324 | result.push(transaction); 325 | } 326 | 327 | expect(result[0]).toHaveProperty('cleared', 'uncleared'); 328 | }); 329 | 330 | it('should respect payee configuration', async () => { 331 | const config = getConfig(); 332 | jest.spyOn(config, 'payee', 'get').mockReturnValueOnce('Test Payee'); 333 | 334 | const result: Array = []; 335 | for await (const transaction of getAmazonTransactions()) { 336 | result.push(transaction); 337 | } 338 | 339 | expect(result[0]).toHaveProperty('payee_name', 'Test Payee'); 340 | }); 341 | 342 | it('should use different import_ids for identical items', async () => { 343 | const result: Array = []; 344 | for await (const transaction of getAmazonTransactions()) { 345 | result.push(transaction); 346 | } 347 | 348 | expect(result[1].import_id).not.toEqual(result[2]); 349 | }); 350 | 351 | it('should use credentials from config', async () => { 352 | await getAmazonTransactions().next(); 353 | 354 | // eslint-disable-next-line 355 | const [[{ username, password, otpFn }]] = AmazonOrderReportsApi.mock.calls as any; 356 | await expect(username()).resolves.toEqual('user@example.com'); 357 | await expect(password()).resolves.toEqual('pass123456'); 358 | await expect(otpFn()).resolves.toEqual('54321'); 359 | }); 360 | 361 | it('should prompt for username/password/otp if not provided in config', async () => { 362 | const config = getConfig(); 363 | jest 364 | .spyOn(config, 'amazonUsername', 'get') 365 | .mockReturnValueOnce((undefined as unknown) as string); 366 | jest 367 | .spyOn(config, 'amazonPassword', 'get') 368 | .mockReturnValueOnce((undefined as unknown) as string); 369 | jest 370 | .spyOn(config, 'amazonOtpCode', 'get') 371 | .mockReturnValueOnce((undefined as unknown) as string); 372 | 373 | mocked(inquirer.prompt) 374 | .mockResolvedValueOnce({ p: 'test@example.com' }) 375 | .mockResolvedValueOnce({ p: 'password1234' }) 376 | .mockResolvedValueOnce({ p: '384739' }); 377 | 378 | await getAmazonTransactions().next(); 379 | 380 | // eslint-disable-next-line 381 | const [[{ username, password, otpFn }]] = AmazonOrderReportsApi.mock.calls as any; 382 | await expect(username()).resolves.toEqual('test@example.com'); 383 | await expect(password()).resolves.toEqual('password1234'); 384 | await expect(otpFn()).resolves.toEqual('384739'); 385 | }); 386 | 387 | it('should use cached cookies', async () => { 388 | const cookies = [ 389 | { 390 | name: 'session-id-time', 391 | value: '2082787201l', 392 | domain: '.amazon.com', 393 | path: '/', 394 | expires: 1641091354.410037, 395 | size: 26, 396 | httpOnly: false, 397 | secure: false, 398 | session: false, 399 | }, 400 | ]; 401 | 402 | mocked(readCache).mockResolvedValueOnce(cookies); 403 | await getAmazonTransactions().next(); 404 | expect(AmazonOrderReportsApi).toBeCalledWith( 405 | expect.objectContaining({ 406 | cookies, 407 | }), 408 | ); 409 | }); 410 | 411 | it('should save cookies', async () => { 412 | const cookies = [ 413 | { 414 | name: 'session-id-time', 415 | value: '2082787201l', 416 | domain: '.amazon.com', 417 | path: '/', 418 | expires: 1641091354.410037, 419 | size: 26, 420 | httpOnly: false, 421 | secure: false, 422 | session: false, 423 | }, 424 | ]; 425 | 426 | await getAmazonTransactions().next(); 427 | // eslint-disable-next-line 428 | const [[{ saveCookiesFn }]] = AmazonOrderReportsApi.mock.calls as any; 429 | await saveCookiesFn(cookies); 430 | 431 | expect(writeCache).toBeCalledWith('cookies', cookies); 432 | }); 433 | }); 434 | }); 435 | --------------------------------------------------------------------------------