@jaskrowo/xtb2sure 0.0.0-stage → 0.2.1

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xtb2sure contributors
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.
package/README.md CHANGED
@@ -1,3 +1,65 @@
1
- # Temporary Holding Version
1
+ # xtb2sure
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Convert XTB `.xlsx` exports into Sure-compatible CSV files.
4
+
5
+ ## What it does
6
+
7
+ - Reads the `Cash Operations` sheet from an XTB export workbook.
8
+ - Produces two CSV files:
9
+ - transactions CSV (`date,amount,name,account,notes`)
10
+ - investments CSV (`date,ticker,currency,qty,price,account,name`)
11
+
12
+ ## Requirements
13
+
14
+ - Node.js 24
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```bash
25
+ node --experimental-transform-types ./src/cli.ts convert <input.xlsx> --currency <CODE> --account "<ACCOUNT_NAME>"
26
+ ```
27
+
28
+ Options:
29
+
30
+ - `--out-dir <dir>`
31
+ - `--transactions-out <file>`
32
+ - `--investments-out <file>`
33
+ - `--strict`
34
+ - `--dry-run`
35
+ - `--report <file>`
36
+
37
+ Examples:
38
+
39
+ ```bash
40
+ node --experimental-transform-types ./src/cli.ts convert sample_data/IKE/input.xlsx --currency USD --account "XTB IKE"
41
+ ```
42
+
43
+ ```bash
44
+ node --experimental-transform-types ./src/cli.ts convert sample_data/PLN/input.xlsx --currency PLN --account "XTB PLN" --out-dir ./out
45
+
46
+ Or via npm script:
47
+
48
+ ```bash
49
+ npm run convert -- sample_data/PLN/input.xlsx --currency PLN --account "XTB PLN"
50
+ ```
51
+ ```
52
+
53
+ ## Exit codes
54
+
55
+ - `0` success
56
+ - `1` conversion or validation failure
57
+ - `2` usage or config error
58
+
59
+ ## Development
60
+
61
+ Run tests (Node.js test runner):
62
+
63
+ ```bash
64
+ npm test
65
+ ```
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+
3
+ import "../src/cli.ts";
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
- "name": "@jaskrowo/xtb2sure",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@jaskrowo/xtb2sure",
3
+ "version": "0.2.1",
4
+ "description": "Convert XTB .xlsx exports into Sure-compatible CSV files",
5
+ "main": "src/index.ts",
6
+ "engines": {
7
+ "node": "24"
8
+ },
9
+ "type": "module",
10
+ "license": "MIT",
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/domeq/xtb2sure.git"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "bin"
21
+ ],
22
+ "bin": {
23
+ "xtb2sure": "./bin/xtb2sure.js"
24
+ },
25
+ "scripts": {
26
+ "dev": "node --experimental-transform-types --watch src/index.ts",
27
+ "convert": "node --experimental-transform-types ./src/cli.ts convert",
28
+ "format": "biome format --write .",
29
+ "knip": "knip",
30
+ "lint-fix": "biome check --write .",
31
+ "lint": "biome check .",
32
+ "prepare": "husky",
33
+ "release": "release-it",
34
+ "start": "node --experimental-transform-types src/index.ts",
35
+ "test": "node --experimental-transform-types --test",
36
+ "typecheck": "tsc --noEmit"
37
+ },
38
+ "lint-staged": {
39
+ "*": [
40
+ "biome check --write --no-errors-on-unmatched"
41
+ ]
42
+ },
43
+ "imports": {
44
+ "#*": "./src/*"
45
+ },
46
+ "devDependencies": {
47
+ "@biomejs/biome": "2.5.15",
48
+ "@commitlint/cli": "21.2.3",
49
+ "@commitlint/config-conventional": "21.2.3",
50
+ "@release-it/conventional-changelog": "12.0.2",
51
+ "@types/node": "24.19.1",
52
+ "husky": "9.1.7",
53
+ "knip": "6.41.0",
54
+ "lint-staged": "17.6.0",
55
+ "release-it": "21.1.1",
56
+ "typescript": "5.9.3"
57
+ },
58
+ "dependencies": {
59
+ "commander": "15.0.0",
60
+ "csv-stringify": "6.9.0",
61
+ "xlsx": "0.18.5"
62
+ }
63
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env -S node --experimental-transform-types
2
+
3
+ import process from "node:process";
4
+ import { Command, CommanderError } from "commander";
5
+ import { convertFile } from "./index.ts";
6
+
7
+ const EXIT_SUCCESS = 0;
8
+ const EXIT_CONVERSION_FAILURE = 1;
9
+ const EXIT_USAGE_ERROR = 2;
10
+
11
+ interface ConvertCommandOptions {
12
+ account: string;
13
+ currency: string;
14
+ dryRun: boolean;
15
+ investmentsOut?: string;
16
+ outDir?: string;
17
+ report?: string;
18
+ strict: boolean;
19
+ transactionsOut?: string;
20
+ }
21
+
22
+ async function main(argv: string[]): Promise<number> {
23
+ let exitCode = EXIT_SUCCESS;
24
+
25
+ const program = new Command();
26
+ program
27
+ .name("xtb2sure")
28
+ .description("Convert XTB .xlsx exports into Sure-compatible CSV files")
29
+ .showHelpAfterError()
30
+ .exitOverride();
31
+
32
+ program
33
+ .command("convert")
34
+ .argument("<input.xlsx>", "Path to XTB export workbook")
35
+ .requiredOption("--currency <code>", "Account currency for all investment rows")
36
+ .requiredOption("--account <name>", "Account label used in generated CSV files")
37
+ .option("--out-dir <dir>", "Output directory for default file names")
38
+ .option("--transactions-out <file>", "Transactions CSV path")
39
+ .option("--investments-out <file>", "Investments CSV path")
40
+ .option("--strict", "Fail on first conversion issue", false)
41
+ .option("--dry-run", "Do not write output files", false)
42
+ .option("--report <file>", "Write conversion report JSON to file")
43
+ .action(async (inputPath: string, options: ConvertCommandOptions) => {
44
+ try {
45
+ const result = await convertFile({
46
+ account: options.account,
47
+ currency: options.currency,
48
+ dryRun: options.dryRun,
49
+ inputPath,
50
+ investmentsOut: options.investmentsOut,
51
+ outDir: options.outDir,
52
+ report: options.report,
53
+ strict: options.strict,
54
+ transactionsOut: options.transactionsOut,
55
+ });
56
+
57
+ const totals = result.reportData.totals;
58
+ console.error(
59
+ `Converted rows=${totals.sourceRows}, transactions=${totals.transactionRows}, investments=${totals.investmentRows}, ignored=${totals.ignoredRows}, issues=${totals.issues}`,
60
+ );
61
+
62
+ if (options.dryRun) {
63
+ console.error("Dry run enabled: files were not written.");
64
+ } else {
65
+ console.error(`Transactions CSV: ${result.transactionsOutPath}`);
66
+ console.error(`Investments CSV: ${result.investmentsOutPath}`);
67
+ }
68
+
69
+ if (totals.issues > 0 && options.strict) {
70
+ exitCode = EXIT_CONVERSION_FAILURE;
71
+ }
72
+ } catch (error) {
73
+ console.error(error instanceof Error ? error.message : String(error));
74
+ exitCode = EXIT_CONVERSION_FAILURE;
75
+ }
76
+ });
77
+
78
+ try {
79
+ await program.parseAsync(argv, { from: "user" });
80
+
81
+ return exitCode;
82
+ } catch (error) {
83
+ if (error instanceof CommanderError) {
84
+ if (error.code === "commander.helpDisplayed") {
85
+ return EXIT_SUCCESS;
86
+ }
87
+
88
+ return EXIT_USAGE_ERROR;
89
+ }
90
+
91
+ console.error(error instanceof Error ? error.message : String(error));
92
+ return EXIT_CONVERSION_FAILURE;
93
+ }
94
+ }
95
+
96
+ main(process.argv.slice(2))
97
+ .then((code) => {
98
+ process.exitCode = code;
99
+ })
100
+ .catch((error) => {
101
+ console.error(error instanceof Error ? error.message : String(error));
102
+ process.exitCode = EXIT_CONVERSION_FAILURE;
103
+ });
package/src/convert.ts ADDED
@@ -0,0 +1,110 @@
1
+ import { excelSerialToYYYYMMDD } from "./date.ts";
2
+ import {
3
+ buildInvestmentName,
4
+ buildTransactionNotes,
5
+ classifyOperation,
6
+ formatQtyOutput,
7
+ parseTradeValues,
8
+ } from "./operations.ts";
9
+ import type { CashOperationRow, ConversionResult, InvestmentRecord, TransactionRecord } from "./types.ts";
10
+
11
+ export interface ConvertOptions {
12
+ account: string;
13
+ currency: string;
14
+ strict: boolean;
15
+ }
16
+
17
+ export function convertRows(rows: CashOperationRow[], options: ConvertOptions): ConversionResult {
18
+ const transactions: TransactionRecord[] = [];
19
+ const investments: InvestmentRecord[] = [];
20
+ const issues: ConversionResult["issues"] = [];
21
+ let ignoredRows = 0;
22
+
23
+ for (const [index, row] of rows.entries()) {
24
+ const typeClass = classifyOperation(row.Type);
25
+
26
+ if (row.Type === "Total") {
27
+ ignoredRows += 1;
28
+ continue;
29
+ }
30
+
31
+ if (typeClass === "ignored") {
32
+ ignoredRows += 1;
33
+ continue;
34
+ }
35
+
36
+ try {
37
+ const date = excelSerialToYYYYMMDD(row.Time);
38
+
39
+ if (typeClass === "transaction") {
40
+ transactions.push({
41
+ account: options.account,
42
+ amount: normalizeAmount(row.Amount),
43
+ date,
44
+ name: row.Type,
45
+ notes: buildTransactionNotes(row),
46
+ });
47
+ } else {
48
+ const { qty, qtyHadExplicitDecimal } = parseTradeValues(row.Comment);
49
+ const signedQtyValue = row.Type === "Stock sell" ? forceNegative(qty) : forcePositive(qty);
50
+ const signedQty = formatQtyOutput(signedQtyValue, row.Instrument, qtyHadExplicitDecimal, row.Product);
51
+ const price = calculateUnitPriceFromAmount(row.Amount, signedQtyValue);
52
+
53
+ investments.push({
54
+ account: options.account,
55
+ currency: options.currency,
56
+ date,
57
+ name: buildInvestmentName(row),
58
+ price,
59
+ qty: signedQty,
60
+ ticker: row.Ticker,
61
+ });
62
+ }
63
+ } catch (error) {
64
+ issues.push({
65
+ message: error instanceof Error ? error.message : String(error),
66
+ operationId: row.ID,
67
+ rowIndex: index + 2,
68
+ });
69
+
70
+ if (options.strict) {
71
+ break;
72
+ }
73
+ }
74
+ }
75
+
76
+ if (rows.some((row) => row.Product.trim() !== "" && row.Product.trim() !== "IKE")) {
77
+ investments.sort((a, b) => a.date.localeCompare(b.date));
78
+ }
79
+
80
+ return {
81
+ ignoredRows,
82
+ investments,
83
+ issues,
84
+ transactions,
85
+ };
86
+ }
87
+
88
+ function normalizeAmount(amount: number): string {
89
+ return amount.toFixed(2);
90
+ }
91
+
92
+ function forceNegative(qty: string): string {
93
+ const num = Math.abs(Number(qty));
94
+ return String(-num);
95
+ }
96
+
97
+ function forcePositive(qty: string): string {
98
+ const num = Math.abs(Number(qty));
99
+ return String(num);
100
+ }
101
+
102
+ function calculateUnitPriceFromAmount(amount: number, qty: string): string {
103
+ const qtyNumber = Math.abs(Number(qty));
104
+ if (!Number.isFinite(amount) || !Number.isFinite(qtyNumber) || qtyNumber === 0) {
105
+ throw new Error(`Cannot calculate price from amount=${amount} and qty=${qty}`);
106
+ }
107
+
108
+ const price = Math.abs(amount) / qtyNumber;
109
+ return String(Number(price.toFixed(8)));
110
+ }
package/src/csv.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { stringify } from "csv-stringify/sync";
2
+
3
+ export function toCsv(headers: string[], rows: string[][]): string {
4
+ return stringify([headers, ...rows]);
5
+ }
package/src/date.ts ADDED
@@ -0,0 +1,22 @@
1
+ const EXCEL_EPOCH_UTC_MS = Date.UTC(1899, 11, 30);
2
+ const DAY_MS = 24 * 60 * 60 * 1000;
3
+
4
+ function excelSerialToDate(serial: number): Date {
5
+ if (!Number.isFinite(serial)) {
6
+ throw new Error(`Invalid Excel serial date: ${serial}`);
7
+ }
8
+
9
+ const utcMs = EXCEL_EPOCH_UTC_MS + Math.round(serial * DAY_MS);
10
+ return new Date(utcMs);
11
+ }
12
+
13
+ function formatDateYYYYMMDD(date: Date): string {
14
+ const year = date.getUTCFullYear();
15
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
16
+ const day = String(date.getUTCDate()).padStart(2, "0");
17
+ return `${year}-${month}-${day}`;
18
+ }
19
+
20
+ export function excelSerialToYYYYMMDD(serial: number): string {
21
+ return formatDateYYYYMMDD(excelSerialToDate(serial));
22
+ }
package/src/excel.ts ADDED
@@ -0,0 +1,105 @@
1
+ import xlsx from "xlsx";
2
+ import type { CashOperationRow } from "./types.ts";
3
+
4
+ const { readFile, utils } = xlsx;
5
+
6
+ const REQUIRED_COLUMNS = [
7
+ "Type",
8
+ "Instrument",
9
+ "Ticker",
10
+ "Time",
11
+ "Amount",
12
+ "ID",
13
+ "Comment",
14
+ "Product",
15
+ "Position ID",
16
+ ] as const;
17
+
18
+ export function readCashOperationsSheet(inputPath: string): CashOperationRow[] {
19
+ const workbook = withSuppressedXlsxWarnings(() => readFile(inputPath, { cellDates: false, raw: true }));
20
+ const worksheet = workbook.Sheets["Cash Operations"];
21
+
22
+ if (!worksheet) {
23
+ throw new Error("Input workbook is missing required sheet: Cash Operations");
24
+ }
25
+
26
+ const matrix = utils.sheet_to_json<unknown[]>(worksheet, {
27
+ header: 1,
28
+ defval: "",
29
+ raw: true,
30
+ });
31
+
32
+ const headerIndex = matrix.findIndex(
33
+ (row) => Array.isArray(row) && row[0] === "Type" && row.includes("Time") && row.includes("Comment"),
34
+ );
35
+
36
+ if (headerIndex < 0) {
37
+ throw new Error("Could not locate Cash Operations header row");
38
+ }
39
+
40
+ const headers = matrix[headerIndex] as unknown[];
41
+ const headerToIndex = new Map<string, number>();
42
+ headers.forEach((header, index) => {
43
+ headerToIndex.set(String(header), index);
44
+ });
45
+
46
+ const missingColumns = REQUIRED_COLUMNS.filter((column) => !headerToIndex.has(column));
47
+ if (missingColumns.length > 0) {
48
+ throw new Error(`Missing required columns in Cash Operations: ${missingColumns.join(", ")}`);
49
+ }
50
+
51
+ const bodyRows = matrix
52
+ .slice(headerIndex + 1)
53
+ .filter((row) => Array.isArray(row) && String(row[0]).trim().length > 0);
54
+ return bodyRows.map((row) => normalizeRowFromArray(row, headerToIndex));
55
+ }
56
+
57
+ function withSuppressedXlsxWarnings<T>(fn: () => T): T {
58
+ const originalConsoleError = console.error;
59
+ console.error = (...args: unknown[]) => {
60
+ if (typeof args[0] === "string" && args[0].includes("Bad uncompressed size:")) {
61
+ return;
62
+ }
63
+
64
+ originalConsoleError(...args);
65
+ };
66
+
67
+ try {
68
+ return fn();
69
+ } finally {
70
+ console.error = originalConsoleError;
71
+ }
72
+ }
73
+
74
+ function normalizeRowFromArray(row: unknown[], headerToIndex: Map<string, number>): CashOperationRow {
75
+ const get = (column: string): unknown => row[headerToIndex.get(column) ?? -1];
76
+
77
+ return {
78
+ Type: stringValue(get("Type")),
79
+ Instrument: stringValue(get("Instrument")),
80
+ Ticker: stringValue(get("Ticker")),
81
+ Time: numberValue(get("Time")),
82
+ Amount: numberValue(get("Amount")),
83
+ ID: stringValue(get("ID")),
84
+ Comment: stringValue(get("Comment")),
85
+ Product: stringValue(get("Product")),
86
+ "Position ID": stringValue(get("Position ID")),
87
+ };
88
+ }
89
+
90
+ function stringValue(value: unknown): string {
91
+ if (value === null || value === undefined) {
92
+ return "";
93
+ }
94
+
95
+ return String(value);
96
+ }
97
+
98
+ function numberValue(value: unknown): number {
99
+ const num = typeof value === "number" ? value : Number(value);
100
+ if (!Number.isFinite(num)) {
101
+ return Number.NaN;
102
+ }
103
+
104
+ return num;
105
+ }
package/src/index.ts ADDED
@@ -0,0 +1,93 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { convertRows } from "./convert.ts";
4
+ import { readCashOperationsSheet } from "./excel.ts";
5
+ import { investmentsToCsv, transactionsToCsv } from "./output.ts";
6
+
7
+ export interface ConvertFileOptions {
8
+ inputPath: string;
9
+ currency: string;
10
+ account: string;
11
+ outDir?: string;
12
+ transactionsOut?: string;
13
+ investmentsOut?: string;
14
+ strict?: boolean;
15
+ dryRun?: boolean;
16
+ report?: string;
17
+ }
18
+
19
+ export interface ConvertFileResult {
20
+ transactionsCsv: string;
21
+ investmentsCsv: string;
22
+ transactionsOutPath: string;
23
+ investmentsOutPath: string;
24
+ reportData: {
25
+ inputPath: string;
26
+ totals: {
27
+ sourceRows: number;
28
+ transactionRows: number;
29
+ investmentRows: number;
30
+ ignoredRows: number;
31
+ issues: number;
32
+ };
33
+ issues: Array<{ rowIndex: number; operationId?: string; message: string }>;
34
+ };
35
+ }
36
+
37
+ export async function convertFile(options: ConvertFileOptions): Promise<ConvertFileResult> {
38
+ const rows = readCashOperationsSheet(options.inputPath);
39
+ const result = convertRows(rows, {
40
+ account: options.account,
41
+ currency: options.currency,
42
+ strict: Boolean(options.strict),
43
+ });
44
+
45
+ const transactionsCsv = transactionsToCsv(result.transactions);
46
+ const investmentsCsv = investmentsToCsv(result.investments);
47
+
48
+ const resolvedOutDir = options.outDir
49
+ ? path.resolve(options.outDir)
50
+ : path.dirname(path.resolve(options.inputPath));
51
+ const baseName = path.basename(options.inputPath, path.extname(options.inputPath));
52
+ const transactionsOutPath = path.resolve(
53
+ options.transactionsOut ?? path.join(resolvedOutDir, `${baseName}.transactions.csv`),
54
+ );
55
+ const investmentsOutPath = path.resolve(
56
+ options.investmentsOut ?? path.join(resolvedOutDir, `${baseName}.investments.csv`),
57
+ );
58
+
59
+ if (!options.dryRun) {
60
+ await mkdir(path.dirname(transactionsOutPath), { recursive: true });
61
+ await mkdir(path.dirname(investmentsOutPath), { recursive: true });
62
+ await writeFile(transactionsOutPath, transactionsCsv, "utf8");
63
+ await writeFile(investmentsOutPath, investmentsCsv, "utf8");
64
+ }
65
+
66
+ const reportData = {
67
+ inputPath: path.resolve(options.inputPath),
68
+ totals: {
69
+ sourceRows: rows.length,
70
+ transactionRows: result.transactions.length,
71
+ investmentRows: result.investments.length,
72
+ ignoredRows: result.ignoredRows,
73
+ issues: result.issues.length,
74
+ },
75
+ issues: result.issues,
76
+ };
77
+
78
+ if (options.report) {
79
+ const reportPath = path.resolve(options.report);
80
+ if (!options.dryRun) {
81
+ await mkdir(path.dirname(reportPath), { recursive: true });
82
+ await writeFile(reportPath, `${JSON.stringify(reportData, null, 2)}\n`, "utf8");
83
+ }
84
+ }
85
+
86
+ return {
87
+ transactionsCsv,
88
+ investmentsCsv,
89
+ transactionsOutPath,
90
+ investmentsOutPath,
91
+ reportData,
92
+ };
93
+ }
@@ -0,0 +1,113 @@
1
+ import type { CashOperationRow } from "./types.ts";
2
+
3
+ const TRANSACTION_TYPES = new Set([
4
+ "Deposit",
5
+ "Free funds interest",
6
+ "Free funds interest tax",
7
+ "IKE deposit",
8
+ "IKZE deposit",
9
+ "Withholding tax",
10
+ ]);
11
+
12
+ const INVESTMENT_TYPES = new Set(["Stock purchase", "Stock sell"]);
13
+
14
+ export function classifyOperation(type: string): "transaction" | "investment" | "ignored" {
15
+ if (TRANSACTION_TYPES.has(type)) {
16
+ return "transaction";
17
+ }
18
+
19
+ if (INVESTMENT_TYPES.has(type)) {
20
+ return "investment";
21
+ }
22
+
23
+ return "ignored";
24
+ }
25
+
26
+ export interface ParsedTradeValues {
27
+ price: string;
28
+ qty: string;
29
+ qtyHadExplicitDecimal: boolean;
30
+ }
31
+
32
+ export function parseTradeValues(comment: string): ParsedTradeValues {
33
+ const normalized = comment.replace(/,/g, ".");
34
+ const buySellMatch = normalized.match(
35
+ /(?:\bBUY\b|\bSELL\b)\s+([+-]?\d+(?:\.\d+)?)(?:\/[+-]?\d+(?:\.\d+)?)?\s*@\s*([+-]?\d+(?:\.\d+)?)/i,
36
+ );
37
+
38
+ const qtyMatch = buySellMatch
39
+ ? [buySellMatch[0], buySellMatch[1]]
40
+ : normalized.match(/\b([+-]?\d+(?:\.\d+)?)\b\s*@/);
41
+ const priceMatch = buySellMatch ? [buySellMatch[0], buySellMatch[2]] : normalized.match(/@\s*([+-]?\d+(?:\.\d+)?)/);
42
+
43
+ if (!qtyMatch || !priceMatch) {
44
+ throw new Error(`Could not parse qty/price from comment: ${comment}`);
45
+ }
46
+
47
+ const rawQty = qtyMatch[1];
48
+ const rawPrice = priceMatch[1];
49
+ if (!rawQty || !rawPrice) {
50
+ throw new Error(`Could not parse qty/price from comment: ${comment}`);
51
+ }
52
+
53
+ return {
54
+ price: normalizeNumberString(rawPrice),
55
+ qty: normalizeNumberString(rawQty),
56
+ qtyHadExplicitDecimal: rawQty.includes("."),
57
+ };
58
+ }
59
+
60
+ function normalizeNumberString(value: string): string {
61
+ const parsed = Number(value);
62
+ if (!Number.isFinite(parsed)) {
63
+ throw new Error(`Invalid numeric value: ${value}`);
64
+ }
65
+
66
+ if (value.includes(".") && Number.isInteger(parsed)) {
67
+ return parsed.toFixed(1);
68
+ }
69
+
70
+ return String(parsed);
71
+ }
72
+
73
+ export function buildTransactionNotes(row: CashOperationRow): string {
74
+ const comment = row.Comment?.trim();
75
+ const base = comment ? `XTB ${row.ID}: ${comment}` : `XTB ${row.ID}`;
76
+ const product = row.Product?.trim();
77
+
78
+ if (!product || product === "IKE" || product === "IKZE") {
79
+ return base;
80
+ }
81
+
82
+ return `${base} [subaccount: ${product}]`;
83
+ }
84
+
85
+ export function buildInvestmentName(row: CashOperationRow): string {
86
+ if (row.Product?.trim() === "IKE") {
87
+ return `XTB ${row.ID}: ${row.Type}`;
88
+ }
89
+
90
+ const instrument = row.Instrument?.trim();
91
+ if (instrument) {
92
+ return `${instrument} - ${row.Type} (XTB ${row.ID})`;
93
+ }
94
+
95
+ return `XTB ${row.ID}: ${row.Type}`;
96
+ }
97
+
98
+ export function formatQtyOutput(
99
+ qty: string,
100
+ instrument: string,
101
+ qtyHadExplicitDecimal: boolean,
102
+ product: string,
103
+ ): string {
104
+ if (product.trim() === "IKE") {
105
+ return qtyHadExplicitDecimal ? qty : String(Number(qty));
106
+ }
107
+
108
+ if (qtyHadExplicitDecimal) {
109
+ return qty;
110
+ }
111
+
112
+ return instrument.trim() ? `${qty}.0` : qty;
113
+ }
package/src/output.ts ADDED
@@ -0,0 +1,24 @@
1
+ import { toCsv } from "./csv.ts";
2
+ import type { InvestmentRecord, TransactionRecord } from "./types.ts";
3
+
4
+ export function transactionsToCsv(records: TransactionRecord[]): string {
5
+ return toCsv(
6
+ ["date", "amount", "name", "account", "notes"],
7
+ records.map((record) => [record.date, record.amount, record.name, record.account, record.notes]),
8
+ );
9
+ }
10
+
11
+ export function investmentsToCsv(records: InvestmentRecord[]): string {
12
+ return toCsv(
13
+ ["date", "ticker", "currency", "qty", "price", "account", "name"],
14
+ records.map((record) => [
15
+ record.date,
16
+ record.ticker,
17
+ record.currency,
18
+ record.qty,
19
+ record.price,
20
+ record.account,
21
+ record.name,
22
+ ]),
23
+ );
24
+ }
package/src/types.ts ADDED
@@ -0,0 +1,42 @@
1
+ export interface CashOperationRow {
2
+ Type: string;
3
+ Instrument: string;
4
+ Ticker: string;
5
+ Time: number;
6
+ Amount: number;
7
+ ID: string;
8
+ Comment: string;
9
+ Product: string;
10
+ "Position ID": string;
11
+ }
12
+
13
+ export interface TransactionRecord {
14
+ date: string;
15
+ amount: string;
16
+ name: string;
17
+ account: string;
18
+ notes: string;
19
+ }
20
+
21
+ export interface InvestmentRecord {
22
+ date: string;
23
+ ticker: string;
24
+ currency: string;
25
+ qty: string;
26
+ price: string;
27
+ account: string;
28
+ name: string;
29
+ }
30
+
31
+ interface ConversionIssue {
32
+ rowIndex: number;
33
+ operationId?: string;
34
+ message: string;
35
+ }
36
+
37
+ export interface ConversionResult {
38
+ transactions: TransactionRecord[];
39
+ investments: InvestmentRecord[];
40
+ ignoredRows: number;
41
+ issues: ConversionIssue[];
42
+ }