diff --git a/README.md b/README.md
index 2ab28af..b431a76 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,7 @@ to login to your Amazon account, retrieve your transations and submits them to Y
functionality, which generates e-mails from Amazon, [see below](#email-notifications) a suggestion for handling these.
## Pre-requisites
+
- [Node.js](https://nodejs.org/) >= 15.x
- [Your YNAB personal access token](https://api.youneedabudget.com/#personal-access-tokens)
- Your Amazon.com login credentials
@@ -23,6 +24,7 @@ npm install -g amazon-ynab-sync
```
On some systems if you're installing to a privileged location you may need:
+
```
sudo npm install -g amazon-ynab-sync --unsafe-perm=true
```
@@ -34,13 +36,13 @@ Ensure you have the Node.js `bin` directory in your environment path.
Ensure you have created a budget and an unlinked account in YNAB in which you want to record your Amazon transactions
(I generally use a Cash account). In these examples I'll use the following as samples values:
-| Option | Value |
-| - | - |
-| Amazon username | test@example.com |
-| Amazon password | password123 |
-| YNAB access token | 437e0a95e9ce155e5deae9d105305988cac9f4664f480650cc18d3327cae36ec |
-| YNAB budget name | My Budget |
-| YNAB Amazon.com account name | Amazon.com |
+| Option | Value |
+| ---------------------------- | ---------------------------------------------------------------- |
+| Amazon username | test@example.com |
+| Amazon password | password123 |
+| YNAB access token | 437e0a95e9ce155e5deae9d105305988cac9f4664f480650cc18d3327cae36ec |
+| YNAB budget name | My Budget |
+| YNAB Amazon.com account name | Amazon.com |
### Basic usage
@@ -73,6 +75,7 @@ amazon-ynab-sync
```
#### 1Password
+
```
AMAZON_USERNAME="$(op get item amazon.com --fields username)" \
AMAZON_PASSWORD="$(op get item amazon.com --fields password)" \
@@ -92,12 +95,13 @@ amazon-ynab-sync
Options can also be saved in a config file. The location of this file is platform-dependent:
| Plaform | Location |
-|---------|-----------------------------------------------------------------------------------------------------|
+| ------- | --------------------------------------------------------------------------------------------------- |
| Linux | $XDG_CONFIG_HOME/amazon-ynab-sync/config.json
or
~/.config/amazon-ynab-sync/config.json |
| macOS | ~/Library/Preferences/amazon-ynab-sync/config.json |
| Windows | %AppData%\amazon-ynab-sync\Config\config.json |
#### Example config.json
+
```
{
"amazonUsername": "test@example.com",
@@ -109,20 +113,21 @@ Options can also be saved in a config file. The location of this file is platfor
## Options
-| Command-line option | Environment Variable | Config file | Description | Default |
-|---------------------|----------------------|-----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------|
-| --amazon-otp-code | AMAZON_OTP_CODE | amazonOtpCode | Amazon OTP/2SV code | |
-| --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 | |
-| --amazon-password | AMAZON_PASSWORD | amazonPassword | Amazon password | |
-| --amazon-username | AMAZON_USERNAME | amazonUsername | Amazon username | |
-| --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 |
+| Command-line option | Environment Variable | Config file | Description | Default |
+| ------------------- | -------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| --amazon-otp-code | AMAZON_OTP_CODE | amazonOtpCode | Amazon OTP/2SV code | |
+| --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 | |
+| --amazon-password | AMAZON_PASSWORD | amazonPassword | Amazon password | |
+| --amazon-username | AMAZON_USERNAME | amazonUsername | Amazon username | |
+| --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 |
| --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 |
-| --debug-mode | DEBUG_MODE | debugMode | Run the internal browser in visible/slo-mo mode | false |
-| --log-level | LOG_LEVEL | logLevel | Level of logs to output. Possible values: "debug", "info", "error", "none", "silly" | info |
-| --start-date | START_DATE | startDate | Only sync transactions which appear after this date. | 30 days ago |
-| --ynab-access-token | YNAB_ACCESS_TOKEN | ynabAccessToken | [YNAB personal access token](https://api.youneedabudget.com/#personal-access-tokens) | |
-| --ynab-account-name | YNAB_ACCOUNT_NAME | ynabAccountName | Name of YNAB account in which you wish to record Amazon transactions | |
-| --ynab-budget-name | YNAB_BUDGET_NAME | ynabBudgetName | Name of the YNAB budget containing the above account | |
+| --cleared | CLEARED | cleared | Whether transactions should be added as cleared by default | true |
+| --debug-mode | DEBUG_MODE | debugMode | Run the internal browser in visible/slo-mo mode | false |
+| --log-level | LOG_LEVEL | logLevel | Level of logs to output. Possible values: "debug", "info", "error", "none", "silly" | info |
+| --start-date | START_DATE | startDate | Only sync transactions which appear after this date. | 30 days ago |
+| --ynab-access-token | YNAB_ACCESS_TOKEN | ynabAccessToken | [YNAB personal access token](https://api.youneedabudget.com/#personal-access-tokens) | |
+| --ynab-account-name | YNAB_ACCOUNT_NAME | ynabAccountName | Name of YNAB account in which you wish to record Amazon transactions | |
+| --ynab-budget-name | YNAB_BUDGET_NAME | ynabBudgetName | Name of the YNAB budget containing the above account | |
## Email notifications
@@ -137,7 +142,7 @@ from:(no-reply@amazon.com) subject:(Your order history report)
## Troubleshooting
In some cases on Linux you may need to install some additional Puppeteer dependencies manually. If you get error messages regarding
-failure to launch the browser process, see the
+failure to launch the browser process, see the
[Puppeteer troubleshooting section](https://github.com/puppeteer/puppeteer/blob/main/docs/troubleshooting.md#chrome-headless-doesnt-launch-on-unix).
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.
diff --git a/package-lock.json b/package-lock.json
index bcc7a0c..1985155 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5,7 +5,7 @@
"requires": true,
"packages": {
"": {
- "version": "1.0.1",
+ "version": "1.0.2",
"license": "MIT",
"dependencies": {
"amazon-order-reports-api": "^3.2.1",
@@ -16,7 +16,7 @@
"make-dir": "^3.1.0",
"winston": "^3.3.3",
"yargs": "^15.4.1",
- "ynab": "^1.21.0"
+ "ynab-client": "npm:ynab@^1.21.0"
},
"bin": {
"amazon-ynab-sync": "lib/index.js"
@@ -17639,7 +17639,8 @@
"node": ">=6"
}
},
- "node_modules/ynab": {
+ "node_modules/ynab-client": {
+ "name": "ynab",
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/ynab/-/ynab-1.21.0.tgz",
"integrity": "sha512-ho9kwzKlGsCFDrw2r/RQFX1nTZzXDOu9beR/jKkaplGeDZuTI5X2L+wCMnAoetuaSx1awxNJ/VNCG2PW45jBpg==",
@@ -31400,8 +31401,8 @@
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true
},
- "ynab": {
- "version": "1.21.0",
+ "ynab-client": {
+ "version": "npm:ynab@1.21.0",
"resolved": "https://registry.npmjs.org/ynab/-/ynab-1.21.0.tgz",
"integrity": "sha512-ho9kwzKlGsCFDrw2r/RQFX1nTZzXDOu9beR/jKkaplGeDZuTI5X2L+wCMnAoetuaSx1awxNJ/VNCG2PW45jBpg==",
"requires": {
diff --git a/package.json b/package.json
index 6ea9f52..91742e2 100644
--- a/package.json
+++ b/package.json
@@ -32,7 +32,7 @@
"make-dir": "^3.1.0",
"winston": "^3.3.3",
"yargs": "^15.4.1",
- "ynab": "^1.21.0"
+ "ynab-client": "npm:ynab@^1.21.0"
},
"devDependencies": {
"@commitlint/cli": "^11.0.0",
diff --git a/src/__mocks__/config.ts b/src/__mocks__/config.ts
index ad3ebab..a8efb26 100644
--- a/src/__mocks__/config.ts
+++ b/src/__mocks__/config.ts
@@ -5,8 +5,11 @@ const mocks = {
amazonPassword: jest.fn(() => 'pass123456'),
amazonUsername: jest.fn(() => 'user@example.com'),
cacheDir: jest.fn(() => '/path/to/cache/'),
+ cleared: jest.fn(() => true),
logLevel: jest.fn(() => 'none'),
ynabAccessToken: jest.fn(() => 'f82918ba-4aa7-4805-b9be-fe5e87eaacf3'),
+ ynabAccountName: jest.fn(() => 'Amazon.com'),
+ ynabBudgetName: jest.fn(() => 'Budget'),
};
const config = Object.defineProperties(
diff --git a/src/__mocks__/ynab-client.ts b/src/__mocks__/ynab-client.ts
new file mode 100644
index 0000000..ba703a6
--- /dev/null
+++ b/src/__mocks__/ynab-client.ts
@@ -0,0 +1,17 @@
+import { SaveTransaction } from 'ynab-client';
+
+export const mocks = {
+ accounts: {
+ getAccounts: jest.fn(),
+ },
+ budgets: {
+ getBudgets: jest.fn(),
+ },
+ transactions: {
+ createTransactions: jest.fn(),
+ },
+};
+
+export const API = jest.fn(() => mocks);
+
+export { SaveTransaction };
diff --git a/src/__mocks__/ynab.ts b/src/__mocks__/ynab.ts
deleted file mode 100644
index 949ca24..0000000
--- a/src/__mocks__/ynab.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export const mocks = {
- budgets: {
- getBudgets: jest.fn()
- },
- accounts: {
- getAccounts: jest.fn()
- }
-};
-
-export const API = jest.fn(() => mocks);
diff --git a/src/amazon.test.ts b/src/amazon.test.ts
index d9fd435..44cb125 100644
--- a/src/amazon.test.ts
+++ b/src/amazon.test.ts
@@ -10,7 +10,6 @@ import {
mocks as amazonOrderReportsApiMocks,
} from './__mocks__/amazon-order-reports-api';
-jest.mock('amazon-order-reports-api');
jest.mock('inquirer');
jest.mock('./cache');
jest.mock('./config');
@@ -108,6 +107,7 @@ describe('amazon', () => {
expect(result).toEqual([
{
amount: -21750,
+ cleared: 'cleared',
date: '2020-01-03',
import_id: '6128e2d42a9b71f2680e46bd90c53fcdb8fe',
memo: 'Some Product',
@@ -115,6 +115,7 @@ describe('amazon', () => {
},
{
amount: 75980,
+ cleared: 'cleared',
date: '2020-01-14',
import_id: '9b026229af8f0894ddcd8b575d28859e7357',
memo: '2 x Some kind of coat',
@@ -122,6 +123,7 @@ describe('amazon', () => {
},
{
amount: 75980,
+ cleared: 'cleared',
date: '2020-01-14',
import_id: '113728ab6700037b5a466b9b01dd0806f26c',
memo: '2 x Some kind of coat',
@@ -130,6 +132,18 @@ describe('amazon', () => {
]);
});
+ it('should respect cleared configuration', async () => {
+ const config = getConfig();
+ jest.spyOn(config, 'cleared', 'get').mockReturnValueOnce(false);
+
+ const result: Array = [];
+ for await (const transaction of getAmazonTransactions()) {
+ result.push(transaction);
+ }
+
+ expect(result[0]).toHaveProperty('cleared', 'uncleared');
+ });
+
it('should use different import_ids for identical items', async () => {
const result: Array = [];
for await (const transaction of getAmazonTransactions()) {
diff --git a/src/amazon.ts b/src/amazon.ts
index a30cf2d..64787f5 100644
--- a/src/amazon.ts
+++ b/src/amazon.ts
@@ -1,7 +1,7 @@
import { AmazonOrderReportsApi, Cookie, LogLevel } from 'amazon-order-reports-api';
import hasha from 'hasha';
import inquirer from 'inquirer';
-import { SaveTransaction } from 'ynab';
+import { SaveTransaction } from 'ynab-client';
import { readCache, writeCache } from './cache';
import { getConfig } from './config';
@@ -44,6 +44,9 @@ const createTransation = (
amount: number,
): AmazonTransaction => ({
amount: Math.round(amount * 1000),
+ cleared: config.cleared
+ ? SaveTransaction.ClearedEnum.Cleared
+ : SaveTransaction.ClearedEnum.Uncleared,
date: `${date.toISOString().split('T')[0]}`,
import_id: generateImportId(
[type, orderId, date, asinIsbn, title, seller, quantity, amount],
diff --git a/src/config.ts b/src/config.ts
index 6aa1cf5..54ac1da 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -13,6 +13,7 @@ export interface Config {
amazonOtpSecret?: string;
cacheDir: string;
configDir: string;
+ cleared: boolean;
debugMode: boolean;
logLevel: string;
startDate: string;
@@ -72,6 +73,11 @@ export const getConfig = (): Config => {
describe: 'Location of config directory',
type: 'string',
})
+ .option('cleared', {
+ default: true,
+ describe: 'Whether transactions should be added as cleared by default',
+ type: 'boolean',
+ })
.option('debug-mode', {
default: false,
describe: 'Run internal browser in visible mode and run in slo-mo mode',
diff --git a/src/index.ts b/src/index.ts
index 5b9e721..8685d3c 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,14 +1,10 @@
#!/usr/bin/env node
import batch from 'it-batch';
-import { API as YnabApi } from 'ynab';
import { getAmazonTransactions } from './amazon';
import { makeCacheDir } from './cache';
-import { getConfig } from './config';
import logger from './logger';
-import { getAccountId, getBudgetId } from './ynab';
-
-const config = getConfig();
+import { addTransactions } from './ynab';
const initialize = async (): Promise => {
await makeCacheDir();
@@ -17,10 +13,6 @@ const initialize = async (): Promise => {
(async () => {
await initialize();
- const budgetId = await getBudgetId(config.ynabBudgetName);
- const accountId = await getAccountId(config.ynabBudgetName, config.ynabAccountName);
- const ynabApi = new YnabApi(config.ynabAccessToken);
-
logger.info('Retrieving reports from Amazon');
let count = 0;
for await (const transactionBatch of batch(getAmazonTransactions(), 100)) {
@@ -30,12 +22,9 @@ const initialize = async (): Promise => {
try {
logger.debug(`Submitting batch of ${transactionBatch.length} transactions`);
- await ynabApi.transactions.createTransactions(budgetId, {
- transactions: transactionBatch.map((t) => ({ ...t, account_id: accountId })),
- });
+ await addTransactions(transactionBatch);
count += transactionBatch.length;
} catch (err) {
- logger.error(`Error during YNAB API call: ${err.error?.detail ?? err.message ?? err.code}`);
break;
}
}
diff --git a/src/ynab.test.ts b/src/ynab.test.ts
index cd68a6a..23a5a9e 100644
--- a/src/ynab.test.ts
+++ b/src/ynab.test.ts
@@ -1,10 +1,9 @@
import mockdate from 'mockdate';
import { mocked } from 'ts-jest/utils';
import { readCache, writeCache } from './cache';
-import { getAccountId, getBudgetId } from './ynab';
-import { mocks as ynabMocks } from './__mocks__/ynab';
+import { addTransactions, getAccountId, getBudgetId } from './ynab';
+import { mocks as ynabMocks, SaveTransaction } from './__mocks__/ynab-client';
-jest.mock('ynab');
jest.mock('./cache');
jest.mock('./config');
@@ -36,23 +35,23 @@ describe('account', () => {
budgets: [
{
id: budgetId,
- name: 'my budget'
- }
- ]
- }
+ name: 'my budget',
+ },
+ ],
+ },
});
const result = await getBudgetId('my budget');
expect(mocked(writeCache)).toBeCalledWith(
['f82918ba-4aa7-4805-b9be-fe5e87eaacf3', 'my budget'],
- budgetId
+ budgetId,
);
expect(result).toEqual(budgetId);
});
it('throw error if budget does not exist', async () => {
expect(getAccountId('my budget', 'amazon.com account')).rejects.toThrow(
- 'Unable to find budget'
+ 'Unable to find budget',
);
});
});
@@ -65,7 +64,7 @@ describe('account', () => {
const result = await getAccountId('my budget', 'amazon.com account');
expect(mock).toBeCalledWith(
['f82918ba-4aa7-4805-b9be-fe5e87eaacf3', 'my budget', 'amazon.com account'],
- 3600000
+ 3600000,
);
expect(result).toEqual(accountId);
});
@@ -79,10 +78,10 @@ describe('account', () => {
budgets: [
{
id: budgetId,
- name: 'my budget'
- }
- ]
- }
+ name: 'my budget',
+ },
+ ],
+ },
});
ynabMocks.accounts.getAccounts.mockResolvedValueOnce({
@@ -90,17 +89,17 @@ describe('account', () => {
accounts: [
{
id: accountId,
- name: 'amazon.com account'
- }
- ]
- }
+ name: 'amazon.com account',
+ },
+ ],
+ },
});
const result = await getAccountId('my budget', 'amazon.com account');
expect(ynabMocks.accounts.getAccounts).toBeCalledWith(budgetId);
expect(mocked(writeCache)).toBeCalledWith(
['f82918ba-4aa7-4805-b9be-fe5e87eaacf3', 'my budget', 'amazon.com account'],
- accountId
+ accountId,
);
expect(result).toEqual(accountId);
});
@@ -113,14 +112,82 @@ describe('account', () => {
budgets: [
{
id: budgetId,
- name: 'my budget'
- }
- ]
- }
+ name: 'my budget',
+ },
+ ],
+ },
});
expect(getAccountId('my budget', 'amazon.com account')).rejects.toThrow(
- 'Unable to find account'
+ 'Unable to find account',
+ );
+ });
+ });
+
+ describe('addTransactions', () => {
+ it('should send transactions to YNAB', async () => {
+ const budgetId = 'f4cc821a-d79a-4c45-86d8-3c477352cbdd';
+ const accountId = '42ed9b93-4f94-4164-ac88-72eb54d818ed';
+
+ ynabMocks.budgets.getBudgets
+ .mockResolvedValueOnce({
+ data: {
+ budgets: [
+ {
+ id: budgetId,
+ name: 'Budget',
+ },
+ ],
+ },
+ })
+ .mockResolvedValueOnce({
+ data: {
+ budgets: [
+ {
+ id: budgetId,
+ name: 'Budget',
+ },
+ ],
+ },
+ });
+
+ ynabMocks.accounts.getAccounts.mockResolvedValueOnce({
+ data: {
+ accounts: [
+ {
+ id: accountId,
+ name: 'Amazon.com',
+ },
+ ],
+ },
+ });
+
+ await addTransactions([
+ {
+ amount: 12345,
+ cleared: SaveTransaction.ClearedEnum.Cleared,
+ date: '2020-12-31',
+ import_id: 'b10500b5a731520b3fd242589d6cc0bc4fc1',
+ memo: 'Very small rocks',
+ payee_name: 'Amazon.com',
+ },
+ ]);
+
+ expect(ynabMocks.transactions.createTransactions).toBeCalledWith(
+ 'f4cc821a-d79a-4c45-86d8-3c477352cbdd',
+ {
+ transactions: [
+ {
+ account_id: '42ed9b93-4f94-4164-ac88-72eb54d818ed',
+ amount: 12345,
+ cleared: 'cleared',
+ date: '2020-12-31',
+ import_id: 'b10500b5a731520b3fd242589d6cc0bc4fc1',
+ memo: 'Very small rocks',
+ payee_name: 'Amazon.com',
+ },
+ ],
+ },
);
});
});
diff --git a/src/ynab.ts b/src/ynab.ts
index 1cdca13..fce2c0b 100644
--- a/src/ynab.ts
+++ b/src/ynab.ts
@@ -1,5 +1,6 @@
-import { API as YnabApi } from 'ynab';
+import { API as YnabApi, ErrorResponse } from 'ynab-client';
import { readCache, writeCache } from './cache';
+import { AmazonTransaction } from './amazon';
import { getConfig } from './config';
import logger from './logger';
@@ -13,6 +14,9 @@ const isAccountId = (val: unknown): val is AccountId => typeof val === 'string';
type BudgetId = string;
const isBudgetid = (val: unknown): val is BudgetId => typeof val === 'string';
+const isErrorResponse = (val: unknown): val is ErrorResponse =>
+ (val as ErrorResponse)?.error?.detail === 'string';
+
export const getBudgetId = async (budgetName: string): Promise => {
const ynabApi = new YnabApi(config.ynabAccessToken);
@@ -66,3 +70,22 @@ export const getAccountId = async (budgetName: string, accountName: string): Pro
return account.id;
};
+
+export const addTransactions = async (transactions: Array) => {
+ const ynabApi = new YnabApi(config.ynabAccessToken);
+
+ const budgetId = await getBudgetId(config.ynabBudgetName);
+ const accountId = await getAccountId(config.ynabBudgetName, config.ynabAccountName);
+
+ try {
+ await ynabApi.transactions.createTransactions(budgetId, {
+ transactions: transactions.map((t) => ({ ...t, account_id: accountId })),
+ });
+ } catch (err) {
+ if (isErrorResponse(err)) {
+ logger.error(`Error during YNAB API call: ${err.error.detail}`);
+ }
+
+ throw err;
+ }
+};