@jaskrowo/xtb2sure 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.editorconfig +30 -0
  2. package/.github/actions/setup/action.yml +15 -0
  3. package/.github/actions/validate-pr-title/action.yml +18 -0
  4. package/.github/pull_request_template.md +3 -0
  5. package/.github/workflows/main.yml +51 -0
  6. package/.github/workflows/npm-audit.yml +23 -0
  7. package/.github/workflows/pr.yml +20 -0
  8. package/.github/workflows/validate.yml +60 -0
  9. package/.husky/commit-msg +1 -0
  10. package/.husky/pre-commit +1 -0
  11. package/.release-it.cjs +41 -0
  12. package/CODE_OF_CONDUCT.md +23 -0
  13. package/CONTRIBUTING.md +26 -0
  14. package/LICENSE +21 -0
  15. package/README.md +64 -2
  16. package/SECURITY.md +11 -0
  17. package/biome.json +20 -0
  18. package/commitlint.config.cjs +12 -0
  19. package/config/default.mjs +11 -0
  20. package/docs/PRD.md +141 -0
  21. package/knip.config.js +5 -0
  22. package/package.json +54 -5
  23. package/renovate.json +10 -0
  24. package/src/cli.ts +103 -0
  25. package/src/convert.ts +110 -0
  26. package/src/csv.ts +5 -0
  27. package/src/date.ts +22 -0
  28. package/src/excel.ts +105 -0
  29. package/src/index.ts +93 -0
  30. package/src/operations.ts +113 -0
  31. package/src/output.ts +24 -0
  32. package/src/types.ts +42 -0
  33. package/test/cli.test.ts +148 -0
  34. package/test/date.test.ts +8 -0
  35. package/test/fixtures/IKE/input.xlsx +0 -0
  36. package/test/fixtures/IKE/output_investments.csv +3 -0
  37. package/test/fixtures/IKE/output_transactions.csv +3 -0
  38. package/test/fixtures/PLN/input.xlsx +0 -0
  39. package/test/fixtures/PLN/output_investments.csv +5 -0
  40. package/test/fixtures/PLN/output_transactions.csv +6 -0
  41. package/test/integration.test.ts +171 -0
  42. package/test/operations.test.ts +23 -0
  43. package/tsconfig.json +24 -0
@@ -0,0 +1,148 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { access, mkdtemp, readFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+ import xlsx from "xlsx";
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = path.dirname(__filename);
12
+ const rootDir = path.resolve(__dirname, "..");
13
+ const cliPath = path.join(rootDir, "src", "cli.ts");
14
+
15
+ test("CLI validates required --currency and --account args", async () => {
16
+ const result = await runCli([
17
+ "convert",
18
+ path.join(rootDir, "test", "fixtures", "IKE", "input.xlsx"),
19
+ "--currency",
20
+ "USD",
21
+ ]);
22
+
23
+ assert.equal(result.code, 2);
24
+ assert.match(result.stderr, /required option/);
25
+ });
26
+
27
+ test("CLI generates output files", async () => {
28
+ const tmpDir = await mkdtemp(path.join(os.tmpdir(), "xtb2sure-cli-"));
29
+ const txPath = path.join(tmpDir, "tx.csv");
30
+ const invPath = path.join(tmpDir, "inv.csv");
31
+
32
+ const result = await runCli([
33
+ "convert",
34
+ path.join(rootDir, "test", "fixtures", "IKE", "input.xlsx"),
35
+ "--currency",
36
+ "USD",
37
+ "--account",
38
+ "XTB IKE",
39
+ "--transactions-out",
40
+ txPath,
41
+ "--investments-out",
42
+ invPath,
43
+ ]);
44
+
45
+ assert.equal(result.code, 0);
46
+ await access(txPath);
47
+ await access(invPath);
48
+ });
49
+
50
+ test("CLI dry-run does not create output files", async () => {
51
+ const tmpDir = await mkdtemp(path.join(os.tmpdir(), "xtb2sure-dry-"));
52
+ const txPath = path.join(tmpDir, "tx.csv");
53
+ const invPath = path.join(tmpDir, "inv.csv");
54
+
55
+ const result = await runCli([
56
+ "convert",
57
+ path.join(rootDir, "test", "fixtures", "IKE", "input.xlsx"),
58
+ "--currency",
59
+ "USD",
60
+ "--account",
61
+ "XTB IKE",
62
+ "--transactions-out",
63
+ txPath,
64
+ "--investments-out",
65
+ invPath,
66
+ "--dry-run",
67
+ ]);
68
+
69
+ assert.equal(result.code, 0);
70
+ await assert.rejects(() => access(txPath));
71
+ await assert.rejects(() => access(invPath));
72
+ });
73
+
74
+ test("CLI strict mode fails when mapping issue is encountered", async () => {
75
+ const tmpDir = await mkdtemp(path.join(os.tmpdir(), "xtb2sure-strict-"));
76
+ const inputPath = path.join(tmpDir, "bad.xlsx");
77
+ const reportPath = path.join(tmpDir, "report.json");
78
+ await writeInvalidTradeWorkbook(inputPath);
79
+
80
+ const strictResult = await runCli([
81
+ "convert",
82
+ inputPath,
83
+ "--currency",
84
+ "USD",
85
+ "--account",
86
+ "XTB TEST",
87
+ "--strict",
88
+ "--report",
89
+ reportPath,
90
+ ]);
91
+
92
+ assert.equal(strictResult.code, 1);
93
+
94
+ const relaxedResult = await runCli([
95
+ "convert",
96
+ inputPath,
97
+ "--currency",
98
+ "USD",
99
+ "--account",
100
+ "XTB TEST",
101
+ "--report",
102
+ reportPath,
103
+ ]);
104
+
105
+ assert.equal(relaxedResult.code, 0);
106
+ const report = JSON.parse(await readFile(reportPath, "utf8"));
107
+ assert.equal(report.totals.issues, 1);
108
+ });
109
+
110
+ function runCli(args: string[]): Promise<{ code: number | null; stdout: string; stderr: string }> {
111
+ return new Promise((resolve, reject) => {
112
+ const child = spawn(process.execPath, ["--experimental-transform-types", cliPath, ...args], {
113
+ cwd: rootDir,
114
+ stdio: ["ignore", "pipe", "pipe"],
115
+ });
116
+
117
+ let stdout = "";
118
+ let stderr = "";
119
+
120
+ child.stdout.on("data", (chunk) => {
121
+ stdout += String(chunk);
122
+ });
123
+
124
+ child.stderr.on("data", (chunk) => {
125
+ stderr += String(chunk);
126
+ });
127
+
128
+ child.on("error", reject);
129
+ child.on("close", (code) => {
130
+ resolve({ code, stdout, stderr });
131
+ });
132
+ });
133
+ }
134
+
135
+ async function writeInvalidTradeWorkbook(outPath: string): Promise<void> {
136
+ const worksheet = xlsx.utils.aoa_to_sheet([
137
+ ["Account number", "123"],
138
+ ["Cash Operations"],
139
+ ["Date from (UTC)", 46000],
140
+ ["Date to (UTC)", 46001],
141
+ ["Type", "Instrument", "Ticker", "Category", "Time", "Amount", "ID", "Comment", "Product", "Position ID"],
142
+ ["Stock purchase", "Test instrument", "TEST.UK", "", 46000.5, 123.45, "1", "BROKEN COMMENT", "My Trades", ""],
143
+ ]);
144
+
145
+ const workbook = xlsx.utils.book_new();
146
+ xlsx.utils.book_append_sheet(workbook, worksheet, "Cash Operations");
147
+ xlsx.writeFile(workbook, outPath);
148
+ }
@@ -0,0 +1,8 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { excelSerialToYYYYMMDD } from "../src/date.ts";
4
+
5
+ test("excelSerialToYYYYMMDD converts known fixture serials", () => {
6
+ assert.equal(excelSerialToYYYYMMDD(46266.43755445602), "2026-09-01");
7
+ assert.equal(excelSerialToYYYYMMDD(46079.041666666664), "2026-02-26");
8
+ });
Binary file
@@ -0,0 +1,3 @@
1
+ date,ticker,currency,qty,price,account,name
2
+ 2026-04-30,EIMI.UK,USD,24,51.81,XTB IKE,XTB 900000003: Stock purchase
3
+ 2026-04-30,IPOL.UK,USD,0.6747,35.68993627,XTB IKE,XTB 900000004: Stock purchase
@@ -0,0 +1,3 @@
1
+ date,amount,name,account,notes
2
+ 2026-09-01,0.11,Free funds interest,XTB IKE,XTB 900000001: Mock interest period 2026-08
3
+ 2026-04-30,14556.14,IKE deposit,XTB IKE,XTB 900000002: Mock transfer in from account 100001
Binary file
@@ -0,0 +1,5 @@
1
+ date,ticker,currency,qty,price,account,name
2
+ 2026-01-05,IUSQ.DE,PLN,1.0,93.88,XTB PLN,MSCI ACWI - Stock purchase (XTB 910000005)
3
+ 2026-01-06,IPOL.UK,PLN,3.0,34.04666667,XTB PLN,MSCI Poland - Stock purchase (XTB 910000007)
4
+ 2026-01-09,SXR8.DE,PLN,-0.1019,637.68400393,XTB PLN,Core S&P 500 - Stock sell (XTB 910000006)
5
+ 2026-01-21,EIMI.UK,PLN,114.0,47.33,XTB PLN,Core MSCI EM IMI - Stock purchase (XTB 910000008)
@@ -0,0 +1,6 @@
1
+ date,amount,name,account,notes
2
+ 2026-09-04,5000.00,Deposit,XTB PLN,XTB 910000001: Mock bank deposit txn=MOCK-TXN-001 [subaccount: Investment Plans]
3
+ 2026-03-05,0.01,Free funds interest,XTB PLN,XTB 910000002: Mock free-funds interest 2026-02 [subaccount: My Trades]
4
+ 2026-03-05,-0.01,Free funds interest tax,XTB PLN,XTB 910000003: Mock free-funds interest tax 2026-02 [subaccount: My Trades]
5
+ 2026-01-02,0.93,Withholding tax,XTB PLN,XTB 910000004: mock WHT correction [subaccount: Investment Plans]
6
+ 2026-02-26,-11304.00,IKZE deposit,XTB PLN,XTB 910000009: Mock transfer out to IKZE 200002 [subaccount: My Trades]
@@ -0,0 +1,171 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+ import { readCashOperationsSheet } from "../src/excel.ts";
7
+ import { convertFile } from "../src/index.ts";
8
+ import { classifyOperation } from "../src/operations.ts";
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = path.dirname(__filename);
12
+ const rootDir = path.resolve(__dirname, "..");
13
+
14
+ test("IKE fixture conversion matches expected outputs", async () => {
15
+ const result = await convertFile({
16
+ account: "XTB IKE",
17
+ currency: "USD",
18
+ dryRun: true,
19
+ inputPath: path.join(rootDir, "test", "fixtures", "IKE", "input.xlsx"),
20
+ });
21
+
22
+ const expectedTransactions = await readExpected("test/fixtures/IKE/output_transactions.csv");
23
+ const expectedInvestments = await readExpected("test/fixtures/IKE/output_investments.csv");
24
+
25
+ assert.equal(normalizeEol(result.transactionsCsv), expectedTransactions);
26
+ assert.equal(normalizeEol(result.investmentsCsv), expectedInvestments);
27
+ });
28
+
29
+ test("PLN fixture conversion matches expected outputs", async () => {
30
+ const result = await convertFile({
31
+ account: "XTB PLN",
32
+ currency: "PLN",
33
+ dryRun: true,
34
+ inputPath: path.join(rootDir, "test", "fixtures", "PLN", "input.xlsx"),
35
+ });
36
+
37
+ const expectedTransactions = await readExpected("test/fixtures/PLN/output_transactions.csv");
38
+ const expectedInvestments = await readExpected("test/fixtures/PLN/output_investments.csv");
39
+
40
+ assert.equal(normalizeEol(result.transactionsCsv), expectedTransactions);
41
+ assert.equal(normalizeEol(result.investmentsCsv), expectedInvestments);
42
+ });
43
+
44
+ test("investment ticker regression: output ticker equals source ticker by XTB operation ID", async () => {
45
+ const inputPath = path.join(rootDir, "test", "fixtures", "PLN", "input.xlsx");
46
+ const result = await convertFile({
47
+ account: "XTB PLN",
48
+ currency: "PLN",
49
+ dryRun: true,
50
+ inputPath,
51
+ });
52
+
53
+ const rows = readCashOperationsSheet(inputPath);
54
+ const byId = new Map<string, string>();
55
+
56
+ for (const row of rows) {
57
+ if (classifyOperation(row.Type) === "investment") {
58
+ byId.set(row.ID, row.Ticker);
59
+ }
60
+ }
61
+
62
+ for (const line of normalizeEol(result.investmentsCsv).split("\n").slice(1)) {
63
+ if (!line.trim()) {
64
+ continue;
65
+ }
66
+
67
+ const cells = parseCsvLine(line);
68
+ const ticker = cells[1];
69
+ const name = cells[6];
70
+ assert.ok(typeof ticker === "string" && ticker.length > 0);
71
+ assert.ok(typeof name === "string" && name.length > 0);
72
+ const idMatch = name.match(/XTB\s+(\d+)/);
73
+ assert.ok(idMatch, `Missing XTB ID in row: ${line}`);
74
+ const id = idMatch[1];
75
+ assert.ok(id);
76
+ assert.equal(ticker, byId.get(id));
77
+ }
78
+ });
79
+
80
+ test("investment currency is always taken from CLI argument", async () => {
81
+ const result = await convertFile({
82
+ account: "XTB PLN",
83
+ currency: "PLN",
84
+ dryRun: true,
85
+ inputPath: path.join(rootDir, "test", "fixtures", "PLN", "input.xlsx"),
86
+ });
87
+
88
+ for (const line of normalizeEol(result.investmentsCsv).split("\n").slice(1)) {
89
+ if (!line.trim()) {
90
+ continue;
91
+ }
92
+
93
+ const cells = parseCsvLine(line);
94
+ assert.equal(cells[2], "PLN");
95
+ }
96
+ });
97
+
98
+ test("investment price is derived from Amount and qty", async () => {
99
+ const inputPath = path.join(rootDir, "test", "fixtures", "PLN", "input.xlsx");
100
+ const result = await convertFile({
101
+ account: "XTB PLN",
102
+ currency: "PLN",
103
+ dryRun: true,
104
+ inputPath,
105
+ });
106
+
107
+ const rows = readCashOperationsSheet(inputPath);
108
+ const amountById = new Map<string, number>();
109
+
110
+ for (const row of rows) {
111
+ if (classifyOperation(row.Type) === "investment") {
112
+ amountById.set(row.ID, Math.abs(row.Amount));
113
+ }
114
+ }
115
+
116
+ for (const line of normalizeEol(result.investmentsCsv).split("\n").slice(1)) {
117
+ if (!line.trim()) {
118
+ continue;
119
+ }
120
+
121
+ const cells = parseCsvLine(line);
122
+ const qty = Math.abs(Number(cells[3]));
123
+ const price = Number(cells[4]);
124
+ const name = cells[6] ?? "";
125
+ const idMatch = name.match(/XTB\s+(\d+)/);
126
+ assert.ok(idMatch, `Missing XTB ID in row: ${line}`);
127
+ const id = idMatch[1];
128
+ assert.ok(id);
129
+ const expectedAmount = amountById.get(id);
130
+ assert.ok(typeof expectedAmount === "number", `No amount found for ID ${id}`);
131
+
132
+ const actualAmount = qty * price;
133
+ assert.ok(Math.abs(actualAmount - expectedAmount) < 0.02, `Amount mismatch for ${id}`);
134
+ }
135
+ });
136
+
137
+ async function readExpected(relativePath: string): Promise<string> {
138
+ const value = await readFile(path.join(rootDir, relativePath), "utf8");
139
+ return normalizeEol(value);
140
+ }
141
+
142
+ function normalizeEol(value: string): string {
143
+ return value.replace(/\r\n/g, "\n");
144
+ }
145
+
146
+ function parseCsvLine(line: string): string[] {
147
+ const cells: string[] = [];
148
+ let current = "";
149
+ let inQuotes = false;
150
+
151
+ for (let i = 0; i < line.length; i += 1) {
152
+ const char = line[i];
153
+ if (char === '"') {
154
+ if (inQuotes && line[i + 1] === '"') {
155
+ current += '"';
156
+ i += 1;
157
+ } else {
158
+ inQuotes = !inQuotes;
159
+ }
160
+ } else if (char === "," && !inQuotes) {
161
+ cells.push(current);
162
+ current = "";
163
+ } else {
164
+ current += char;
165
+ }
166
+ }
167
+
168
+ cells.push(current);
169
+
170
+ return cells;
171
+ }
@@ -0,0 +1,23 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { classifyOperation, parseTradeValues } from "../src/operations.ts";
4
+
5
+ test("classifyOperation maps supported operation groups", () => {
6
+ assert.equal(classifyOperation("Deposit"), "transaction");
7
+ assert.equal(classifyOperation("Stock purchase"), "investment");
8
+ assert.equal(classifyOperation("Total"), "ignored");
9
+ });
10
+
11
+ test("parseTradeValues parses BUY format with split quantity", () => {
12
+ const parsed = parseTradeValues("OPEN BUY 7/7.7575 @ 42.750");
13
+ assert.equal(parsed.qty, "7");
14
+ assert.equal(parsed.price, "42.75");
15
+ assert.equal(parsed.qtyHadExplicitDecimal, false);
16
+ });
17
+
18
+ test("parseTradeValues parses SELL format with decimal-preserving integer price", () => {
19
+ const parsed = parseTradeValues("CLOSE SELL 0.9179 @ 1233.0");
20
+ assert.equal(parsed.qty, "0.9179");
21
+ assert.equal(parsed.price, "1233.0");
22
+ assert.equal(parsed.qtyHadExplicitDecimal, true);
23
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "skipLibCheck": true,
4
+ "target": "esnext",
5
+ "resolveJsonModule": true,
6
+ "verbatimModuleSyntax": true,
7
+ "rootDir": ".",
8
+ "allowImportingTsExtensions": true,
9
+ "noEmit": true,
10
+ "module": "NodeNext",
11
+ "esModuleInterop": true,
12
+ "strict": true,
13
+ "noUncheckedIndexedAccess": true,
14
+ "noImplicitOverride": true,
15
+ "forceConsistentCasingInFileNames": true,
16
+ "allowJs": false,
17
+ "baseUrl": "./",
18
+ "paths": {
19
+ "#*": ["./src/*"]
20
+ }
21
+ },
22
+ "include": ["**/*"],
23
+ "exclude": ["node_modules", "dist"]
24
+ }