feat(balance): add returns + transfers section to balance.service
Issue #142 / Bilan #4 — TS bridge for the Modified Dietz command + plain CRUD for transfer linking. Types (`src/shared/types/index.ts`): - `BalanceTransferDirection` ('in' | 'out') - `BalanceAccountTransfer` (raw row) + `BalanceAccountTransferWithTransaction` (joined view) - `AccountReturn` (mirrors the Rust struct, ready to receive the invoke payload as-is) Service (`src/services/balance.service.ts`): - `computeAccountReturn(accountId, periodStart, periodEnd)`: resolves the active profile's `db_filename` from `loadProfiles()` and calls the `compute_account_return` Tauri command. - `linkTransfer(accountId, transactionId, direction, notes?)`: INSERT with duplicate guard (typed `transfer_already_linked` error instead of raw SQL UNIQUE failure). - `unlinkTransfer(accountId, transactionId)`: DELETE with `transfer_not_linked` guard for stale-UI calls. - `listAccountTransfers(accountId, dateRange?)`: joined SELECT for modal/list rendering. - `listLinkedTransactionIds()`: returns a `Set<number>` for the transaction icon (one query, in-memory `.has()` lookups thereafter). - `listAllLinkedTransfersForTooltip()`: returns `Map<transactionId, links[]>` for tooltip rendering. - `suggestTransferDirection(amount)`: pure helper for the modal — maps negative bank amounts to 'in', positive to 'out'. - `isLinkedTransactionFkError(error)`: detects the canonical SQLite "FK constraint failed" text so `transactionService.deleteTransaction` can surface a clear i18n message. - 5 new error codes added to `BalanceErrorCode`. Tests (`balance.service.test.ts`): 22 new vitest cases bringing the file to 85 passed. Mocks `@tauri-apps/api/core` `invoke` and `./profileService` `loadProfiles`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
23ff8466c0
commit
dafdd4ce17
3 changed files with 545 additions and 1 deletions
|
|
@ -4,7 +4,17 @@ vi.mock("./db", () => ({
|
||||||
getDb: vi.fn(),
|
getDb: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
|
invoke: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./profileService", () => ({
|
||||||
|
loadProfiles: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { loadProfiles } from "./profileService";
|
||||||
import {
|
import {
|
||||||
listBalanceCategories,
|
listBalanceCategories,
|
||||||
createBalanceCategory,
|
createBalanceCategory,
|
||||||
|
|
@ -30,6 +40,14 @@ import {
|
||||||
getSnapshotTotalsByCategoryAndDate,
|
getSnapshotTotalsByCategoryAndDate,
|
||||||
getAccountsLatestSnapshot,
|
getAccountsLatestSnapshot,
|
||||||
getAccountsPeriodAnchor,
|
getAccountsPeriodAnchor,
|
||||||
|
computeAccountReturn,
|
||||||
|
linkTransfer,
|
||||||
|
unlinkTransfer,
|
||||||
|
listAccountTransfers,
|
||||||
|
listLinkedTransactionIds,
|
||||||
|
listAllLinkedTransfersForTooltip,
|
||||||
|
isLinkedTransactionFkError,
|
||||||
|
suggestTransferDirection,
|
||||||
} from "./balance.service";
|
} from "./balance.service";
|
||||||
|
|
||||||
const mockSelect = vi.fn();
|
const mockSelect = vi.fn();
|
||||||
|
|
@ -974,3 +992,215 @@ describe("getAccountsPeriodAnchor", () => {
|
||||||
expect(sql).not.toMatch(/WHERE\s+s\.snapshot_date/);
|
expect(sql).not.toMatch(/WHERE\s+s\.snapshot_date/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Returns + transfers (Issue #142)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("computeAccountReturn", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(loadProfiles).mockReset();
|
||||||
|
vi.mocked(invoke).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invokes the Tauri command with the active profile's db_filename", async () => {
|
||||||
|
vi.mocked(loadProfiles).mockResolvedValueOnce({
|
||||||
|
active_profile_id: "p1",
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
id: "p1",
|
||||||
|
name: "Max",
|
||||||
|
color: "#fff",
|
||||||
|
pin_hash: null,
|
||||||
|
db_filename: "max.db",
|
||||||
|
created_at: "0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const fakeReturn = {
|
||||||
|
value_start: 1000,
|
||||||
|
value_end: 1100,
|
||||||
|
net_contributions: 0,
|
||||||
|
return_pct: 0.1,
|
||||||
|
annualized_pct: 0.42,
|
||||||
|
is_partial: false,
|
||||||
|
has_no_transfers_warning: true,
|
||||||
|
};
|
||||||
|
vi.mocked(invoke).mockResolvedValueOnce(fakeReturn);
|
||||||
|
|
||||||
|
const out = await computeAccountReturn(7, "2026-01-01", "2026-04-01");
|
||||||
|
|
||||||
|
expect(out).toEqual(fakeReturn);
|
||||||
|
expect(invoke).toHaveBeenCalledWith("compute_account_return", {
|
||||||
|
dbFilename: "max.db",
|
||||||
|
accountId: 7,
|
||||||
|
periodStart: "2026-01-01",
|
||||||
|
periodEnd: "2026-04-01",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed period dates before invoking the command", async () => {
|
||||||
|
vi.mocked(loadProfiles).mockResolvedValueOnce({
|
||||||
|
active_profile_id: "p1",
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
id: "p1",
|
||||||
|
name: "Max",
|
||||||
|
color: "#fff",
|
||||||
|
pin_hash: null,
|
||||||
|
db_filename: "max.db",
|
||||||
|
created_at: "0",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
computeAccountReturn(1, "not-a-date", "2026-04-01")
|
||||||
|
).rejects.toBeInstanceOf(BalanceServiceError);
|
||||||
|
expect(invoke).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws transfer_active_profile_unknown when no active profile resolves", async () => {
|
||||||
|
vi.mocked(loadProfiles).mockResolvedValueOnce({
|
||||||
|
active_profile_id: "missing",
|
||||||
|
profiles: [],
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
computeAccountReturn(1, "2026-01-01", "2026-04-01")
|
||||||
|
).rejects.toMatchObject({ code: "transfer_active_profile_unknown" });
|
||||||
|
expect(invoke).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("suggestTransferDirection", () => {
|
||||||
|
it("maps negative bank amounts to 'in' (money left bank → arrived in account)", () => {
|
||||||
|
expect(suggestTransferDirection(-100)).toBe("in");
|
||||||
|
});
|
||||||
|
it("maps positive bank amounts to 'out' (money came back from account)", () => {
|
||||||
|
expect(suggestTransferDirection(50)).toBe("out");
|
||||||
|
});
|
||||||
|
it("treats zero as 'out' as a deterministic fallback", () => {
|
||||||
|
expect(suggestTransferDirection(0)).toBe("out");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("linkTransfer", () => {
|
||||||
|
it("rejects an invalid direction without touching the DB", async () => {
|
||||||
|
await expect(
|
||||||
|
// @ts-expect-error testing runtime guard
|
||||||
|
linkTransfer(1, 2, "sideways")
|
||||||
|
).rejects.toBeInstanceOf(BalanceServiceError);
|
||||||
|
expect(mockExecute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards against duplicate links with a typed error", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([{ id: 5 }]);
|
||||||
|
await expect(linkTransfer(1, 2, "in")).rejects.toMatchObject({
|
||||||
|
code: "transfer_already_linked",
|
||||||
|
});
|
||||||
|
expect(mockExecute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts and returns the new transfer id", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
mockExecute.mockResolvedValueOnce({ lastInsertId: 99, rowsAffected: 1 });
|
||||||
|
const id = await linkTransfer(1, 2, "out", " manual ");
|
||||||
|
expect(id).toBe(99);
|
||||||
|
const sql = mockExecute.mock.calls[0][0] as string;
|
||||||
|
expect(sql).toContain("INSERT INTO balance_account_transfers");
|
||||||
|
expect(mockExecute.mock.calls[0][1]).toEqual([1, 2, "out", "manual"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes empty notes to null", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
mockExecute.mockResolvedValueOnce({ lastInsertId: 1, rowsAffected: 1 });
|
||||||
|
await linkTransfer(1, 2, "in", " ");
|
||||||
|
expect(mockExecute.mock.calls[0][1][3]).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unlinkTransfer", () => {
|
||||||
|
it("throws transfer_not_linked when no row was deleted", async () => {
|
||||||
|
mockExecute.mockResolvedValueOnce({ lastInsertId: 0, rowsAffected: 0 });
|
||||||
|
await expect(unlinkTransfer(1, 2)).rejects.toMatchObject({
|
||||||
|
code: "transfer_not_linked",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("succeeds when one row is deleted", async () => {
|
||||||
|
mockExecute.mockResolvedValueOnce({ lastInsertId: 0, rowsAffected: 1 });
|
||||||
|
await expect(unlinkTransfer(1, 2)).resolves.toBeUndefined();
|
||||||
|
expect(mockExecute.mock.calls[0][1]).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listAccountTransfers", () => {
|
||||||
|
it("filters by account_id only when no date range is supplied", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
await listAccountTransfers(7);
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
expect(sql).toContain("FROM balance_account_transfers bat");
|
||||||
|
expect(sql).toContain("JOIN transactions t");
|
||||||
|
expect(sql).toContain("JOIN balance_accounts a");
|
||||||
|
expect(sql).toContain("WHERE bat.account_id = $1");
|
||||||
|
expect(sql).not.toContain("t.date >=");
|
||||||
|
expect(mockSelect.mock.calls[0][1]).toEqual([7]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends inclusive date bounds when supplied", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([]);
|
||||||
|
await listAccountTransfers(7, { from: "2026-01-01", to: "2026-04-01" });
|
||||||
|
const sql = mockSelect.mock.calls[0][0] as string;
|
||||||
|
expect(sql).toContain("t.date >=");
|
||||||
|
expect(sql).toContain("t.date <=");
|
||||||
|
expect(mockSelect.mock.calls[0][1]).toEqual([7, "2026-01-01", "2026-04-01"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listLinkedTransactionIds", () => {
|
||||||
|
it("returns a Set of transaction ids", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([
|
||||||
|
{ transaction_id: 5 },
|
||||||
|
{ transaction_id: 12 },
|
||||||
|
]);
|
||||||
|
const ids = await listLinkedTransactionIds();
|
||||||
|
expect(ids).toBeInstanceOf(Set);
|
||||||
|
expect(ids.has(5)).toBe(true);
|
||||||
|
expect(ids.has(12)).toBe(true);
|
||||||
|
expect(ids.size).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listAllLinkedTransfersForTooltip", () => {
|
||||||
|
it("groups multiple links per transaction id", async () => {
|
||||||
|
mockSelect.mockResolvedValueOnce([
|
||||||
|
{ transaction_id: 1, account_id: 10, account_name: "TFSA", direction: "in" },
|
||||||
|
{ transaction_id: 1, account_id: 20, account_name: "RRSP", direction: "out" },
|
||||||
|
{ transaction_id: 2, account_id: 10, account_name: "TFSA", direction: "in" },
|
||||||
|
]);
|
||||||
|
const map = await listAllLinkedTransfersForTooltip();
|
||||||
|
expect(map.get(1)).toHaveLength(2);
|
||||||
|
expect(map.get(2)).toHaveLength(1);
|
||||||
|
expect(map.get(1)?.[0].account_name).toBe("TFSA");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isLinkedTransactionFkError", () => {
|
||||||
|
it("matches the canonical SQLite FK error text", () => {
|
||||||
|
expect(
|
||||||
|
isLinkedTransactionFkError(new Error("FOREIGN KEY constraint failed"))
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches the wrapped tauri-plugin-sql variant", () => {
|
||||||
|
expect(
|
||||||
|
isLinkedTransactionFkError(
|
||||||
|
new Error("code: 787, message: FOREIGN KEY constraint failed")
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not match unrelated errors", () => {
|
||||||
|
expect(isLinkedTransactionFkError(new Error("something else"))).toBe(false);
|
||||||
|
expect(isLinkedTransactionFkError(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,20 @@
|
||||||
// filesystem / OAuth / license / profile work and the future Modified Dietz
|
// filesystem / OAuth / license / profile work and the future Modified Dietz
|
||||||
// + price-fetch work in Issue #142.
|
// + price-fetch work in Issue #142.
|
||||||
|
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { getDb } from "./db";
|
import { getDb } from "./db";
|
||||||
|
import { loadProfiles } from "./profileService";
|
||||||
import type {
|
import type {
|
||||||
|
AccountReturn,
|
||||||
BalanceAccount,
|
BalanceAccount,
|
||||||
|
BalanceAccountTransfer,
|
||||||
|
BalanceAccountTransferWithTransaction,
|
||||||
BalanceAccountWithCategory,
|
BalanceAccountWithCategory,
|
||||||
BalanceCategory,
|
BalanceCategory,
|
||||||
BalanceCategoryKind,
|
BalanceCategoryKind,
|
||||||
BalanceSnapshot,
|
BalanceSnapshot,
|
||||||
BalanceSnapshotLine,
|
BalanceSnapshotLine,
|
||||||
|
BalanceTransferDirection,
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
import { BALANCE_CURRENCY_CAD } from "../shared/types";
|
import { BALANCE_CURRENCY_CAD } from "../shared/types";
|
||||||
|
|
||||||
|
|
@ -40,7 +46,13 @@ export type BalanceErrorCode =
|
||||||
| "snapshot_priced_quantity_required"
|
| "snapshot_priced_quantity_required"
|
||||||
| "snapshot_priced_unit_price_required"
|
| "snapshot_priced_unit_price_required"
|
||||||
| "snapshot_priced_value_mismatch"
|
| "snapshot_priced_value_mismatch"
|
||||||
| "snapshot_simple_must_be_scalar";
|
| "snapshot_simple_must_be_scalar"
|
||||||
|
// Issue #142 — transfers + returns
|
||||||
|
| "transfer_direction_invalid"
|
||||||
|
| "transfer_already_linked"
|
||||||
|
| "transfer_not_linked"
|
||||||
|
| "transfer_active_profile_unknown"
|
||||||
|
| "transaction_linked_to_balance_account";
|
||||||
|
|
||||||
export class BalanceServiceError extends Error {
|
export class BalanceServiceError extends Error {
|
||||||
readonly code: BalanceErrorCode;
|
readonly code: BalanceErrorCode;
|
||||||
|
|
@ -955,3 +967,251 @@ export async function getAccountsPeriodAnchor(
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Returns + transfers (Issue #142 / Bilan #4)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Two distinct surface areas:
|
||||||
|
//
|
||||||
|
// (1) `computeAccountReturn` — Modified Dietz return for one account over a
|
||||||
|
// period. Lives on the Rust side (`compute_account_return` Tauri command)
|
||||||
|
// because it needs to JOIN snapshots + transfers + transactions and
|
||||||
|
// apply day-precision weighting in a single short-lived connection. The
|
||||||
|
// TS shim resolves the active profile's `db_filename` from `loadProfiles`
|
||||||
|
// and forwards it to the command.
|
||||||
|
//
|
||||||
|
// (2) Transfer linking helpers — `linkTransfer`, `unlinkTransfer`,
|
||||||
|
// `listAccountTransfers`. Plain CRUD on `balance_account_transfers` via
|
||||||
|
// `getDb()`, same pattern as the rest of this file.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the Modified Dietz return for `accountId` over the period
|
||||||
|
* `[periodStart, periodEnd]` (both ISO `YYYY-MM-DD`). Returns the typed
|
||||||
|
* `AccountReturn` shape — see `src/shared/types/index.ts`.
|
||||||
|
*
|
||||||
|
* Resolves the active profile's `db_filename` from `loadProfiles()` so the
|
||||||
|
* caller doesn't have to thread it through every screen. Throws
|
||||||
|
* `transfer_active_profile_unknown` if no active profile is set (should be
|
||||||
|
* impossible in normal app flow, but the service guards it anyway).
|
||||||
|
*/
|
||||||
|
export async function computeAccountReturn(
|
||||||
|
accountId: number,
|
||||||
|
periodStart: string,
|
||||||
|
periodEnd: string
|
||||||
|
): Promise<AccountReturn> {
|
||||||
|
const startNorm = normalizeSnapshotDate(periodStart);
|
||||||
|
const endNorm = normalizeSnapshotDate(periodEnd);
|
||||||
|
const config = await loadProfiles();
|
||||||
|
const profile = config.profiles.find(
|
||||||
|
(p) => p.id === config.active_profile_id
|
||||||
|
);
|
||||||
|
if (!profile) {
|
||||||
|
throw new BalanceServiceError(
|
||||||
|
"transfer_active_profile_unknown",
|
||||||
|
"No active profile is set"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return invoke<AccountReturn>("compute_account_return", {
|
||||||
|
dbFilename: profile.db_filename,
|
||||||
|
accountId,
|
||||||
|
periodStart: startNorm,
|
||||||
|
periodEnd: endNorm,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDirection(
|
||||||
|
direction: BalanceTransferDirection
|
||||||
|
): BalanceTransferDirection {
|
||||||
|
if (direction !== "in" && direction !== "out") {
|
||||||
|
throw new BalanceServiceError(
|
||||||
|
"transfer_direction_invalid",
|
||||||
|
`Invalid transfer direction: ${direction}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggested direction for an unlinked transaction based on its signed amount.
|
||||||
|
* Pure helper so the `LinkTransfersModal` UI can pre-fill the direction
|
||||||
|
* column without round-tripping. Convention: in this codebase, expense
|
||||||
|
* transactions are stored with negative amounts (money leaving the bank).
|
||||||
|
* From the *balance account's* perspective:
|
||||||
|
* - negative bank amount = money left the bank → arrived at the balance
|
||||||
|
* account = `in`
|
||||||
|
* - positive bank amount = money entered the bank = the balance account
|
||||||
|
* gave it back = `out`
|
||||||
|
*/
|
||||||
|
export function suggestTransferDirection(
|
||||||
|
transactionAmount: number
|
||||||
|
): BalanceTransferDirection {
|
||||||
|
return transactionAmount < 0 ? "in" : "out";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link a transaction to a balance account with the given direction.
|
||||||
|
* Throws `transfer_already_linked` if the (transaction, account) pair is
|
||||||
|
* already in the table (UNIQUE constraint).
|
||||||
|
*/
|
||||||
|
export async function linkTransfer(
|
||||||
|
accountId: number,
|
||||||
|
transactionId: number,
|
||||||
|
direction: BalanceTransferDirection,
|
||||||
|
notes?: string | null
|
||||||
|
): Promise<number> {
|
||||||
|
const dir = normalizeDirection(direction);
|
||||||
|
const trimmedNotes = notes ? notes.trim() || null : null;
|
||||||
|
const db = await getDb();
|
||||||
|
// Guard duplicate link with a SELECT — keeps the error typed instead of a
|
||||||
|
// raw "UNIQUE constraint failed" string.
|
||||||
|
const existing = await db.select<{ id: number }[]>(
|
||||||
|
`SELECT id FROM balance_account_transfers
|
||||||
|
WHERE account_id = $1 AND transaction_id = $2`,
|
||||||
|
[accountId, transactionId]
|
||||||
|
);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
throw new BalanceServiceError(
|
||||||
|
"transfer_already_linked",
|
||||||
|
`Transaction ${transactionId} is already linked to account ${accountId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = await db.execute(
|
||||||
|
`INSERT INTO balance_account_transfers (account_id, transaction_id, direction, notes)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
[accountId, transactionId, dir, trimmedNotes]
|
||||||
|
);
|
||||||
|
return result.lastInsertId as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlink a transaction from an account. Throws `transfer_not_linked` if the
|
||||||
|
* pair isn't in the table — keeps callers from silently no-op'ing on a stale
|
||||||
|
* UI state.
|
||||||
|
*/
|
||||||
|
export async function unlinkTransfer(
|
||||||
|
accountId: number,
|
||||||
|
transactionId: number
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await getDb();
|
||||||
|
const result = await db.execute(
|
||||||
|
`DELETE FROM balance_account_transfers
|
||||||
|
WHERE account_id = $1 AND transaction_id = $2`,
|
||||||
|
[accountId, transactionId]
|
||||||
|
);
|
||||||
|
if (result.rowsAffected === 0) {
|
||||||
|
throw new BalanceServiceError(
|
||||||
|
"transfer_not_linked",
|
||||||
|
`No transfer linked transaction ${transactionId} to account ${accountId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List every linked transfer for `accountId`, joined with the transaction
|
||||||
|
* table for date/description/amount. Optional `dateRange` (ISO YYYY-MM-DD,
|
||||||
|
* inclusive both sides) filters by `transactions.date`.
|
||||||
|
*/
|
||||||
|
export async function listAccountTransfers(
|
||||||
|
accountId: number,
|
||||||
|
dateRange?: { from?: string; to?: string }
|
||||||
|
): Promise<BalanceAccountTransferWithTransaction[]> {
|
||||||
|
const params: unknown[] = [accountId];
|
||||||
|
const conditions: string[] = ["bat.account_id = $1"];
|
||||||
|
if (dateRange?.from) {
|
||||||
|
conditions.push(`t.date >= $${params.length + 1}`);
|
||||||
|
params.push(normalizeSnapshotDate(dateRange.from));
|
||||||
|
}
|
||||||
|
if (dateRange?.to) {
|
||||||
|
conditions.push(`t.date <= $${params.length + 1}`);
|
||||||
|
params.push(normalizeSnapshotDate(dateRange.to));
|
||||||
|
}
|
||||||
|
const where = `WHERE ${conditions.join(" AND ")}`;
|
||||||
|
const db = await getDb();
|
||||||
|
return db.select<BalanceAccountTransferWithTransaction[]>(
|
||||||
|
`SELECT bat.id AS id,
|
||||||
|
bat.account_id AS account_id,
|
||||||
|
bat.transaction_id AS transaction_id,
|
||||||
|
bat.direction AS direction,
|
||||||
|
bat.notes AS notes,
|
||||||
|
bat.created_at AS created_at,
|
||||||
|
t.date AS transaction_date,
|
||||||
|
t.description AS transaction_description,
|
||||||
|
t.amount AS transaction_amount,
|
||||||
|
a.name AS account_name
|
||||||
|
FROM balance_account_transfers bat
|
||||||
|
JOIN transactions t ON t.id = bat.transaction_id
|
||||||
|
JOIN balance_accounts a ON a.id = bat.account_id
|
||||||
|
${where}
|
||||||
|
ORDER BY t.date DESC, bat.id DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the set of `transaction_id`s currently linked to ANY balance
|
||||||
|
* account. Used by the transactions table to render the transfer icon
|
||||||
|
* without an N+1 query — the caller receives the full set once per render
|
||||||
|
* and does an in-memory `.has(id)` lookup. Cheap on real-world scales
|
||||||
|
* (typically < 1000 linked transfers per profile).
|
||||||
|
*/
|
||||||
|
export async function listLinkedTransactionIds(): Promise<Set<number>> {
|
||||||
|
const db = await getDb();
|
||||||
|
const rows = await db.select<{ transaction_id: number }[]>(
|
||||||
|
`SELECT DISTINCT transaction_id FROM balance_account_transfers`
|
||||||
|
);
|
||||||
|
return new Set(rows.map((r) => r.transaction_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns transfer info keyed by `transaction_id` for tooltip rendering in
|
||||||
|
* the transactions table. Each transaction maps to an array because a
|
||||||
|
* single transaction *could* be linked to several accounts in principle
|
||||||
|
* (the UNIQUE is on the pair, not on transaction alone).
|
||||||
|
*/
|
||||||
|
export interface LinkedTransferTooltipRow {
|
||||||
|
transaction_id: number;
|
||||||
|
account_id: number;
|
||||||
|
account_name: string;
|
||||||
|
direction: BalanceTransferDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAllLinkedTransfersForTooltip(): Promise<
|
||||||
|
Map<number, LinkedTransferTooltipRow[]>
|
||||||
|
> {
|
||||||
|
const db = await getDb();
|
||||||
|
const rows = await db.select<LinkedTransferTooltipRow[]>(
|
||||||
|
`SELECT bat.transaction_id AS transaction_id,
|
||||||
|
bat.account_id AS account_id,
|
||||||
|
a.name AS account_name,
|
||||||
|
bat.direction AS direction
|
||||||
|
FROM balance_account_transfers bat
|
||||||
|
JOIN balance_accounts a ON a.id = bat.account_id
|
||||||
|
ORDER BY bat.transaction_id`
|
||||||
|
);
|
||||||
|
const map = new Map<number, LinkedTransferTooltipRow[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const list = map.get(r.transaction_id) ?? [];
|
||||||
|
list.push(r);
|
||||||
|
map.set(r.transaction_id, list);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect whether the SQL error returned by `tauri-plugin-sql` is a FK
|
||||||
|
* RESTRICT violation from `balance_account_transfers.transaction_id`. The
|
||||||
|
* plugin surfaces the SQLite error message verbatim, so we match on the
|
||||||
|
* string. Used by `transactionService.deleteTransaction` to surface a
|
||||||
|
* clean i18n error instead of leaking the raw SQL.
|
||||||
|
*/
|
||||||
|
export function isLinkedTransactionFkError(error: unknown): boolean {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error ?? "");
|
||||||
|
// SQLite FK error messages look like:
|
||||||
|
// "FOREIGN KEY constraint failed"
|
||||||
|
// or
|
||||||
|
// "code: 787, message: FOREIGN KEY constraint failed"
|
||||||
|
// Both contain the canonical "FOREIGN KEY constraint failed" substring.
|
||||||
|
return /FOREIGN KEY constraint failed/i.test(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -630,3 +630,57 @@ export interface BalanceSnapshotLine {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Account transfers — added Issue #142 (Bilan #4). Links a transaction to a
|
||||||
|
// balance account so the Modified Dietz return calculator can separate
|
||||||
|
// contributions from gains. Direction follows the account's perspective:
|
||||||
|
// 'in' = capital added (deposit / buy)
|
||||||
|
// 'out' = capital removed (withdrawal / sell)
|
||||||
|
// `transaction_id` ON DELETE RESTRICT — preserves reproducibility of past
|
||||||
|
// returns, the UI must force the user to unlink before deleting the
|
||||||
|
// underlying transaction.
|
||||||
|
export type BalanceTransferDirection = "in" | "out";
|
||||||
|
|
||||||
|
export interface BalanceAccountTransfer {
|
||||||
|
id: number;
|
||||||
|
account_id: number;
|
||||||
|
transaction_id: number;
|
||||||
|
direction: BalanceTransferDirection;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Joined view used by LinkTransfersModal + transaction icon lookup. */
|
||||||
|
export interface BalanceAccountTransferWithTransaction
|
||||||
|
extends BalanceAccountTransfer {
|
||||||
|
transaction_date: string;
|
||||||
|
transaction_description: string;
|
||||||
|
transaction_amount: number;
|
||||||
|
account_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modified Dietz return for one account over a period.
|
||||||
|
* Mirrors the Rust struct in `src-tauri/src/commands/return_calculator.rs`.
|
||||||
|
*
|
||||||
|
* - `value_start` / `value_end`: latest snapshot value ≤ each endpoint, or
|
||||||
|
* null when no snapshot exists.
|
||||||
|
* - `net_contributions`: signed sum of cash flows in the period.
|
||||||
|
* - `return_pct`: Modified Dietz return (0.05 = +5%); null if either
|
||||||
|
* endpoint is missing or denominator is non-positive.
|
||||||
|
* - `annualized_pct`: `(1 + R)^(365/T) - 1`; null for zero-length periods
|
||||||
|
* or whenever `return_pct` is null.
|
||||||
|
* - `is_partial`: true when one endpoint snapshot is missing.
|
||||||
|
* - `has_no_transfers_warning`: true when no transfers were tagged in the
|
||||||
|
* period — return collapses to simple `(V_end - V_start) / V_start` and
|
||||||
|
* the UI surfaces a warning so the user can verify nothing was forgotten.
|
||||||
|
*/
|
||||||
|
export interface AccountReturn {
|
||||||
|
value_start: number | null;
|
||||||
|
value_end: number | null;
|
||||||
|
net_contributions: number;
|
||||||
|
return_pct: number | null;
|
||||||
|
annualized_pct: number | null;
|
||||||
|
is_partial: boolean;
|
||||||
|
has_no_transfers_warning: boolean;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue