Skip to content

Commit

Permalink
Add BUX converter (#80)
Browse files Browse the repository at this point in the history
  • Loading branch information
dickwolff authored Jun 6, 2024
1 parent 42af695 commit 47e869c
Show file tree
Hide file tree
Showing 21 changed files with 545 additions and 20 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/frameworkTesting.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ jobs:
format: 'markdown'
output: 'file'

- name: Add code coverage PR comment
- name: Add code coverage PR comment (if owner)
uses: marocchino/sticky-pull-request-comment@v2
if: github.event_name == 'pull_request' && github.event.pull_request.user.login == 'dickwolff'
with:
Expand Down
2 changes: 1 addition & 1 deletion GitVersion.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
next-version: 0.13.3
next-version: 0.14.0
assembly-informational-format: "{NuGetVersion}"
mode: ContinuousDeployment
branches:
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
This tool allows you to convert a multiple transaction exports (CSV) to an import file that can be read by [Ghostfolio](https://github.com/ghostfolio/ghostfolio/). Currently there is support for:

- [Bitvavo](https://bitvavo.com)
- [BUX](https://bux.com)
- [DEGIRO](https://degiro.com)
- [eToro](https://www.etoro.com/)
- [Finpension](https://finpension.ch)
Expand All @@ -32,7 +33,12 @@ See the transaction export instructions for each of the supported brokers below.

Login to your Bitvavo account and click on your name at the top-right. Next, click "Transaction history". Click the blue "Export" button at the to to create an export. Select one of the CSv options and again click "Export".

![Export instructions for DEGIRO](./assets/export-bitvavo.jpg)
![Export instructions for Bitvavo](./assets/export-bitvavo.jpg)

## BUX
Open the app and go to "Account Value", and then "View History". Click the download icon in the top right corner to download your transaction history. The export will be sent to your email address.

_Due to limitations by BUX, you can request up to 3 CSV exports per month!_.

### DEGIRO

Expand Down Expand Up @@ -183,6 +189,7 @@ You can now run `npm run start [exporttype]`. See the table with run commands be
| Exporter | Run command |
| ----------- | ----------------------------------- |
| Bitvavo | `run start bitvavo` (or `bv`) |
| BUX | `run start bux` |
| DEGIRO | `run start degiro` |
| eToro | `run start etoro` |
| Finpension | `run start finpension` (or `fp`) |
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "export-to-ghostfolio",
"version": "0.13.3",
"version": "0.14.0",
"type": "module",
"description": "Convert multiple broker exports to Ghostfolio import",
"scripts": {
Expand All @@ -16,11 +16,11 @@
"devDependencies": {
"@types/cacache": "^17.0.2",
"@types/jest": "^29.5.11",
"@types/node": "^20.12.12",
"@types/node": "^20.13.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.3",
"ts-jest": "^29.1.4",
"ts-node": "^10.9.2",
"tsx": "^4.10.5",
"tsx": "^4.11.0",
"typescript": "^5.4.5"
},
"dependencies": {
Expand All @@ -32,6 +32,6 @@
"csv-parse": "^5.5.6",
"dayjs": "^1.11.11",
"dotenv": "^16.4.5",
"yahoo-finance2": "^2.11.2"
"yahoo-finance2": "^2.11.3"
}
}
20 changes: 20 additions & 0 deletions samples/bux-export.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Transaction Time (CET),Transaction Category,Transaction Type,Asset Id,Asset Name,Asset Currency,Transaction Currency,Currency Pair,Exchange Rate,Transaction Amount,Trade Amount,Trade Price,Trade Quantity,Cash Balance Amount,Profit And Loss Amount,Profit And Loss Currency
2020-11-18 12:15:23.606000,deposits,Sepa Deposit,,,,EUR,,,500,,,,500,,
2020-11-18 13:02:54.696000,deposits,Sepa Deposit,,,,EUR,,,100,,,,600.03,,
2023-03-21 13:37:29.383000,trades,Buy Trade,NL0011821202,ING,EUR,EUR,EUREUR,1,-542.92,542.92,11.08,49,48.49,,
2023-03-21 13:37:29.384000,fees,Trading Fee,NL0011821202,ING,,EUR,,,-1.5,,,,46.99,,
2023-03-21 13:37:29.384000,trades,Buy Trade,NL0011821202,ING,EUR,EUR,EUREUR,1,-7.08,7.0800092,11.08,0.638989,39.91,,
2023-05-02 11:07:12.872000,fees,Subscription Fee,,,,EUR,,,-2.99,,,,36.92,,
2023-05-05 11:38:06.646000,dividends,Cash Dividend,NL0011821202,ING,EUR,EUR,EUREUR,1,16.4,,,,53.32,,
2023-06-01 18:51:19.620000,fees,Subscription Fee,,,,EUR,,,-2.99,,,,50.33,,
2023-07-01 12:32:03.013000,fees,Subscription Fee,,,,EUR,,,-2.99,,,,47.34,,
2023-07-09 10:25:51.533000,interest,Interest Payment,,,,EUR,,,0.23,,,,47.57,,
2023-08-01 16:19:45.363000,fees,Subscription Fee,,,,EUR,,,-2.99,,,,44.58,,
2023-08-14 16:37:01.245000,dividends,Cash Dividend,NL0011821202,ING,EUR,EUR,EUREUR,1,14.76,,,,59.34,,
2023-09-01 18:28:48.386000,fees,Subscription Fee,,,,EUR,,,-2.99,,,,56.35,,
2023-10-01 16:20:38.540000,fees,Subscription Fee,,,,EUR,,,-2.99,,,,53.36,,
2023-10-05 08:10:02.073000,interest,Interest Payment,,,,EUR,,,0.34,,,,53.7,,
2023-11-05 07:05:37.311000,interest,Interest Payment,,,,EUR,,,0.11,,,,50.82,,
2024-01-19 17:15:46.543000,deposits,Sepa Deposit,,,,EUR,,,10,,,,55.07,,
2024-05-14 13:57:08.427000,withdrawals,Sepa Withdrawal,,,,EUR,,,-1200,,,,1293.6,,
2023-03-21 13:37:29.384000,trades,Sell Trade,NL0011821202,ING,EUR,EUR,EUREUR,1,-7.08,7.0800092,11.08,0.638989,39.91,,
1 change: 1 addition & 0 deletions samples/trading212-export.csv
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ Market buy,2023-12-18 14:30:07.812,US00217D1000,ASTS,"AST SpaceMobile",0.2976400
Market sell,2023-12-26 14:30:05.104,US04634X2027,ASTR,"Astra Space",0.6125400000,1.26,USD,1.10231,-3.08,"EUR",0.70,"EUR",,,,EOF7802023054,,
Dividend (Dividend),2023-12-27 12:05:25,US56035L1044,MAIN,"Main Street Capital",0.1543340000,0.23,USD,Not available,,,0.03,"EUR",0.01,USD,,,,
Dividend (Dividend),2023-12-28 09:32:51,US9078181081,UNP,"Union Pacific",0.0280492000,1.11,USD,Not available,,,0.03,"EUR",0.01,USD,,,,
Dividend (Dividend),2024-01-12 14:14:14,IE00B1XNHC34,INRG,"iShares Global Clean Energy UCITS ETF",0.0280492000,630.11,GBX,Not available,,,17.67,"EUR",15.02,USD,,,,
Interest on cash,2023-11-06 22:06:41.36,,,,,,,,,,0.01,"EUR",,,"Interest on cash",8ffba791-cfc3-4002-b65d-bd63cf483d9d,,
3 changes: 2 additions & 1 deletion samples/xtb-export.csv
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ ID;Type;Time;Symbol;Comment;Amount
516573845;Stocks/ETF purchase;15.03.2024 15:37:59;SPYL.DE;OPEN BUY 26 @ 11.3500;-295.1
515435183;Deposit;14.03.2024 10:30:23;;JP_MORGAN deposit, JP_MORGAN provider transaction id=TXN-C-4233659-166217, JP_MORGAN merchant reference id=TXN-C-4233659-166217, id=10635042;300
514947144;Deposit;13.03.2024 13:00:20;;JP_MORGAN deposit, JP_MORGAN provider transaction id=TXN-C-4232520-290438, JP_MORGAN merchant reference id=TXN-C-4232520-290438, id=10619119;300

419846716;Ações/ETF compra;25.07.2023 20:56:07;GOOGL.US;OPEN BUY 1 @ 122.34;-111.25
419846716;Ações/ETF vende;25.07.2023 20:56:07;GOOGL.US;CLOSE BUY 1 @ 122.34;111.25
7 changes: 6 additions & 1 deletion src/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import dayjs from "dayjs";
import { SecurityService } from "./securityService";
import GhostfolioService from "./ghostfolioService";
import { AbstractConverter } from "./converters/abstractconverter";
import { BitvavoConverter } from "./converters/bitvavoConverter";
import { BuxConverter } from "./converters/buxConverter";
import { DeGiroConverter } from "./converters/degiroConverter";
import { DeGiroConverterV2 } from "./converters/degiroConverterV2";
import { EtoroConverter } from "./converters/etoroConverter";
Expand All @@ -16,7 +18,6 @@ import { SchwabConverter } from "./converters/schwabConverter";
import { SwissquoteConverter } from "./converters/swissquoteConverter";
import { Trading212Converter } from "./converters/trading212Converter";
import { XtbConverter } from "./converters/xtbConverter";
import { BitvavoConverter } from "./converters/bitvavoConverter";

import packageInfo from "../package.json";

Expand Down Expand Up @@ -72,6 +73,10 @@ async function createConverter(converterType: string): Promise<AbstractConverter
console.log("[i] Processing file using Bitvavo converter");
converter = new BitvavoConverter(securityService);
break;
case "bux":
console.log("[i] Processing file using Bux converter");
converter = new BuxConverter(securityService);
break;
case "degiro-v1":
console.log("[i] Processing file using DeGiro converter (V1)");
console.log("[i] NOTE: This version of the DeGiro converter is deprecated and will no longer receive updates.");
Expand Down
92 changes: 92 additions & 0 deletions src/converters/abstractConverter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { SecurityService } from "../securityService";
import { AbstractConverter } from "./abstractconverter";
import YahooFinanceServiceMock from "../testing/yahooFinanceServiceMock";

// Create a test wrapper so the AbstractConverter can be tested.
class TestAbstractConverter extends AbstractConverter {

// This method is covered by other tests
isIgnoredRecord(_: any): boolean {
return false;
}

// This method is covered by other tests.
processFileContents(_: string, successCallback: CallableFunction, __: CallableFunction): void {
return successCallback();
}

// Wrap processHeaders() so it can be tested.
public processHeadersTest(csvFile: string, splitChar?: string): string[] {
return this.processHeaders(csvFile, splitChar);
}

// Wrap logQueryError() so it can be tested.
public logQueryErrorTest(query: string, index: number) {
return this.logQueryError(query, index)
}
}

describe("abstractConverter", () => {

beforeEach(() => {
jest.spyOn(console, "log").mockImplementation(jest.fn());
});

afterEach(() => {
jest.clearAllMocks();
});

it("should construct", () => {

// Act
const sut = new TestAbstractConverter(new SecurityService(new YahooFinanceServiceMock()));

// Assert
expect(sut).toBeTruthy();
});

it("processHeaders() should process headers", () => {

// Arrange
let tempFileContent = "";
tempFileContent += "Type,ISIN,PriceInEUR,PriceInCHF,TransactionTimeCET\n";

const sut = new TestAbstractConverter(new SecurityService(new YahooFinanceServiceMock()));

// Act
const headers = sut.processHeadersTest(tempFileContent, ",");

// Assert
expect(headers.length).toBe(5);
expect(headers[1]).toBe("isin");
expect(headers[2]).toBe("priceInEur");
expect(headers[3]).toBe("priceInChf");
expect(headers[4]).toBe("transactionTimeCet");
});

describe("logQueryError()", () => {
it("should log a query if it was given", () => {
// Arrange
const sut = new TestAbstractConverter(new SecurityService(new YahooFinanceServiceMock()));
const consoleSpy = jest.spyOn(console, "log");

// Act
sut.logQueryErrorTest("AAPL", 1);

// Assert
expect(consoleSpy).toHaveBeenCalledWith("\n[e] An error ocurred while trying to retrieve symbol AAPL (line 3)!\n");
});
});

it("should log without a query if it was not given", () => {
// Arrange
const sut = new TestAbstractConverter(new SecurityService(new YahooFinanceServiceMock()));
const consoleSpy = jest.spyOn(console, "log");

// Act
sut.logQueryErrorTest(undefined, 2);

// Assert
expect(consoleSpy).toHaveBeenCalledWith("\n[e] An error ocurred while trying to retrieve an empty symbol (line 4)!\n");
});
});
4 changes: 3 additions & 1 deletion src/converters/abstractconverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ export abstract class AbstractConverter {
col = col.slice(0, -3) + "Eur";
} else if (col.endsWith("CHF")) {
col = col.slice(0, -3) + "Chf";
} else if (col.endsWith("CET")) {
col = col.slice(0, -3) + "Cet";
}

csvHeaders.push(col);
Expand All @@ -102,7 +104,7 @@ export abstract class AbstractConverter {
* @param query The query that was looked for.
* @param index The index of the line in the input file.
*/
protected logQueryError(query: string, index: number) {
protected logQueryError(query: string | undefined, index: number) {

let message = `\n[e] An error ocurred while trying to retrieve {query} (line ${index + 2})!\n`;

Expand Down
147 changes: 147 additions & 0 deletions src/converters/buxConverter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { BuxConverter } from "./buxConverter";
import { SecurityService } from "../securityService";
import { GhostfolioExport } from "../models/ghostfolioExport";
import YahooFinanceServiceMock from "../testing/yahooFinanceServiceMock";

describe("buxConverter", () => {

beforeEach(() => {
jest.spyOn(console, "log").mockImplementation(jest.fn());
});

afterEach(() => {
jest.clearAllMocks();
});

it("should construct", () => {

// Act
const sut = new BuxConverter(new SecurityService(new YahooFinanceServiceMock()));

// Assert
expect(sut).toBeTruthy();
});

it("should process sample CSV file", (done) => {

// Arange
const sut = new BuxConverter(new SecurityService(new YahooFinanceServiceMock()));
const inputFile = "samples/bux-export.csv";

// Act
sut.readAndProcessFile(inputFile, (actualExport: GhostfolioExport) => {

// Assert
expect(actualExport).toBeTruthy();
expect(actualExport.activities.length).toBeGreaterThan(0);
expect(actualExport.activities.length).toBe(15);

done();
}, () => { done.fail("Should not have an error!"); });
});

describe("should throw an error if", () => {
it("the input file does not exist", (done) => {

// Arrange
const sut = new BuxConverter(new SecurityService(new YahooFinanceServiceMock()));

let tempFileName = "tmp/testinput/bux-filedoesnotexist.csv";

// Act
sut.readAndProcessFile(tempFileName, () => { done.fail("Should not succeed!"); }, (err: Error) => {

// Assert
expect(err).toBeTruthy();

done();
});
});

it("the input file is empty", (done) => {

// Arrange
const sut = new BuxConverter(new SecurityService(new YahooFinanceServiceMock()));

let tempFileContent = "";
tempFileContent += `Transaction Time (CET),Transaction Category,Transaction Type,Asset Id,Asset Name,Asset Currency,Transaction Currency,Currency Pair,Exchange Rate,Transaction Amount,Trade Amount,Trade Price,Trade Quantity,Cash Balance Amount,Profit And Loss Amount,Profit And Loss Currency\n`;

// Act
sut.processFileContents(tempFileContent, () => { done.fail("Should not succeed!"); }, (err: Error) => {

// Assert
expect(err).toBeTruthy();
expect(err.message).toContain("An error ocurred while parsing");

done();
});
});

it("the header and row column count doesn't match", (done) => {

// Arrange
const sut = new BuxConverter(new SecurityService(new YahooFinanceServiceMock()));

let tempFileContent = "";
tempFileContent += `Transaction Time (CET),Transaction Category,Transaction Type,Asset Id,Asset Name,Asset Currency,Transaction Currency,Currency Pair,Exchange Rate,Transaction Amount,Trade Amount,Trade Price,Trade Quantity,Cash Balance Amount,Profit And Loss Amount,Profit And Loss Currency\n`;
tempFileContent += `2023-03-21 13:37:29.383000,trades,Buy Trade,NL0011821202,ING,EUR,EUR,EUREUR,1,-542.92,542.92,11.08,49,48.49,,,,`;

// Act
sut.processFileContents(tempFileContent, () => { done.fail("Should not succeed!"); }, (err: Error) => {

// Assert
expect(err).toBeTruthy();
expect(err.message).toBe("An error ocurred while parsing! Details: Invalid Record Length: columns length is 16, got 18 on line 2");

done();
});
});

it("Yahoo Finance throws an error", (done) => {

// Arrange
let tempFileContent = "";
tempFileContent += `Transaction Time (CET),Transaction Category,Transaction Type,Asset Id,Asset Name,Asset Currency,Transaction Currency,Currency Pair,Exchange Rate,Transaction Amount,Trade Amount,Trade Price,Trade Quantity,Cash Balance Amount,Profit And Loss Amount,Profit And Loss Currency\n`;
tempFileContent += `2023-03-21 13:37:29.383000,trades,Buy Trade,NL0011821202,ING,EUR,EUR,EUREUR,1,-542.92,542.92,11.08,49,48.49,,`;

// Mock Yahoo Finance service to throw error.
const yahooFinanceServiceMock = new YahooFinanceServiceMock();
jest.spyOn(yahooFinanceServiceMock, "search").mockImplementation(() => { throw new Error("Unit test error"); });
const sut = new BuxConverter(new SecurityService(yahooFinanceServiceMock));

// Act
sut.processFileContents(tempFileContent, () => { done.fail("Should not succeed!"); }, (err: Error) => {

// Assert
expect(err).toBeTruthy();
expect(err.message).toContain("Unit test error");

done();
});
});
});

it("should log when Yahoo Finance returns no symbol", (done) => {

// Arrange
let tempFileContent = "";
tempFileContent += `Transaction Time (CET),Transaction Category,Transaction Type,Asset Id,Asset Name,Asset Currency,Transaction Currency,Currency Pair,Exchange Rate,Transaction Amount,Trade Amount,Trade Price,Trade Quantity,Cash Balance Amount,Profit And Loss Amount,Profit And Loss Currency\n`;
tempFileContent += `2023-03-21 13:37:29.383000,trades,Buy Trade,NL0011821202,ING,EUR,EUR,EUREUR,1,-542.92,542.92,11.08,49,48.49,,`;

// Mock Yahoo Finance service to return no quotes.
const yahooFinanceServiceMock = new YahooFinanceServiceMock();
jest.spyOn(yahooFinanceServiceMock, "search").mockImplementation(() => { return Promise.resolve({ quotes: [] }) });
const sut = new BuxConverter(new SecurityService(yahooFinanceServiceMock));

// Bit hacky, but it works.
const consoleSpy = jest.spyOn((sut as any).progress, "log");

// Act
sut.processFileContents(tempFileContent, () => {

expect(consoleSpy).toHaveBeenCalledWith("[i] No result found for buy action for NL0011821202 with currency EUR! Please add this manually..\n");

done();
}, () => done.fail("Should not have an error!"));
});
});
Loading

0 comments on commit 47e869c

Please sign in to comment.