Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions packages/cli/src/__tests__/reconcile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { getDb } from "@finsight/core";
import { afterEach, describe, expect, it } from "vitest";

const tempDirs: string[] = [];
const cliEntry = fileURLToPath(new URL("../index.ts", import.meta.url));

function makeTempDir(): string {
const dir = mkdtempSync(path.join(tmpdir(), "finsight-reconcile-test-"));
tempDirs.push(dir);
return dir;
}

afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});

describe("reconcile mixed-currency account", () => {
it("converts each position before comparing the broker total", () => {
const home = makeTempDir();
const databasePath = path.join(home, "authoritative.db");
const db = getDb(databasePath);
db.$client
.prepare(
`INSERT INTO accounts
(id, name, type, currency, balance, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
"mixed-account",
"Mixed Broker",
"brokerage",
"CNY",
100,
1,
"2026-09-15T00:00:00.000Z",
"2026-09-15T00:00:00.000Z",
);
db.$client
.prepare(
`INSERT INTO exchange_rates
(id, from_currency, to_currency, rate, rate_date, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(
"usd-cny",
"USD",
"CNY",
7,
"2026-09-15",
"2026-09-15T00:00:00.000Z",
);
db.$client
.prepare(
`INSERT INTO exchange_rates
(id, from_currency, to_currency, rate, rate_date, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(
"hkd-cny",
"HKD",
"CNY",
0.9,
"2026-09-15",
"2026-09-15T00:00:00.000Z",
);
db.$client
.prepare(
`INSERT INTO positions
(id, account_id, symbol, quantity, avg_cost, current_price, currency, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
"usd-position",
"mixed-account",
"US-ASSET",
10,
20,
20,
"USD",
"2026-09-15T00:00:00.000Z",
"2026-09-15T00:00:00.000Z",
);
db.$client
.prepare(
`INSERT INTO positions
(id, account_id, symbol, quantity, avg_cost, current_price, currency, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
"hkd-position",
"mixed-account",
"HK-ASSET",
100,
10,
10,
"HKD",
"2026-09-15T00:00:00.000Z",
"2026-09-15T00:00:00.000Z",
);
db.$client.close();
const configDir = path.join(home, ".finsight");
mkdirSync(configDir, { recursive: true });
writeFileSync(
path.join(configDir, "config.json"),
`${JSON.stringify({ base_currency: "CNY" })}\n`,
);

const result = spawnSync(
process.execPath,
["--import", "tsx", cliEntry, "reconcile", "Mixed Broker", "--broker-total", "2400", "--json"],
{
cwd: path.dirname(cliEntry),
encoding: "utf8",
env: { ...process.env, HOME: home, FINSIGHT_DB_PATH: databasePath },
},
);

expect(result.status).toBe(0);
expect(result.stderr).toBe("");
expect(JSON.parse(result.stdout.trim())).toMatchObject({
ok: true,
account: "Mixed Broker",
currency: "CNY",
delta_pct: 0,
computed_total_base: 2400,
reconciliation: {
computed_total: 2400,
broker_total: 2400,
currency: "CNY",
},
});
});
});
44 changes: 22 additions & 22 deletions packages/cli/src/commands/reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { input } from "@inquirer/prompts";
import {
listAccounts,
listPositions,
getNetWorth,
toBase,
getAccountValuation,
getBaseCurrency,
recordReconciliation,
listReconciliations,
} from "@finsight/core";
Expand All @@ -28,16 +28,16 @@ export const reconcileCmd = new Command("reconcile")
accs.find((a) => a.name.toLowerCase().includes(lower));
if (!acc) fail("NOT_FOUND", `Account not found: ${accountName}`, { json: opts.json });

// Compute account total in account currency
// Convert cash and every position into one currency before comparing with
// the broker's total. A broker account may hold multiple currencies.
const accPositions = listPositions(db, acc.id);
const positionsNative = accPositions.reduce(
(s, p) => s + p.current_price * p.quantity,
0,
);
const computedNative = acc.balance + positionsNative;
const nw = getNetWorth(db);
const slice = nw.byAccount.find((a) => a.id === acc.id);
const computedBase = slice?.balance_base ?? toBase(db, computedNative, acc.currency);
const valuation = getAccountValuation(db, acc.id, acc.currency);
if (!valuation) {
fail("NOT_FOUND", `Account not found: ${accountName}`, { json: opts.json });
}
const computedTotal = valuation.amount;
const reconcileCurrency = valuation.currency;
const baseValuation = getAccountValuation(db, acc.id, getBaseCurrency());

// Get broker_total
let brokerTotal: number;
Expand All @@ -52,25 +52,25 @@ export const reconcileCmd = new Command("reconcile")
console.log(chalk.bold(` Reconcile · ${acc.name}`));
console.log(
chalk.dim(
` FinSight computed: ${formatCurrency(computedNative, acc.currency)} (${acc.currency})`,
` FinSight computed: ${formatCurrency(computedTotal, reconcileCurrency)} (${reconcileCurrency})`,
),
);
console.log();
const raw = await input({
message: `Total shown in your broker (${acc.currency}):`,
message: `Total shown in your broker (${reconcileCurrency}):`,
});
brokerTotal = Number(raw);
if (Number.isNaN(brokerTotal))
fail("USER_ERROR", `Invalid amount: ${raw}`, { json: opts.json });
}

const delta = brokerTotal - computedNative;
const pct = computedNative !== 0 ? delta / computedNative : 0;
const delta = brokerTotal - computedTotal;
const pct = computedTotal !== 0 ? delta / computedTotal : 0;

const row = recordReconciliation(db, {
account_id: acc.id,
currency: acc.currency,
computed_total: computedNative,
currency: reconcileCurrency,
computed_total: computedTotal,
broker_total: brokerTotal,
notes: opts.note,
});
Expand All @@ -80,23 +80,23 @@ export const reconcileCmd = new Command("reconcile")
ok: true,
reconciliation: row,
account: acc.name,
currency: acc.currency,
currency: reconcileCurrency,
delta_pct: pct,
computed_total_base: computedBase,
computed_total_base: baseValuation?.amount ?? computedTotal,
positions_with_fallback_price: accPositions.filter((p) => p.current_price === 0)
.length,
});
process.exit(ExitCode.OK);
}

const t = createTable(["", "Amount"]);
t.push(["Broker shows", formatCurrency(brokerTotal, acc.currency)]);
t.push(["FinSight computed", formatCurrency(computedNative, acc.currency)]);
t.push(["Broker shows", formatCurrency(brokerTotal, reconcileCurrency)]);
t.push(["FinSight computed", formatCurrency(computedTotal, reconcileCurrency)]);
const color = Math.abs(pct) < 0.01 ? chalk.green : Math.abs(pct) < 0.05 ? chalk.yellow : chalk.red;
t.push([
"Δ",
color(
`${delta >= 0 ? "+" : ""}${formatCurrency(delta, acc.currency)} (${(pct * 100).toFixed(2)}%)`,
`${delta >= 0 ? "+" : ""}${formatCurrency(delta, reconcileCurrency)} (${(pct * 100).toFixed(2)}%)`,
),
]);
console.log(t.toString());
Expand Down
Loading
Loading