feat(balance): add asset_type column to balance_categories #170

Merged
maximus merged 1 commit from issue-169-asset-type-balance-categories into main 2026-04-29 00:47:02 +00:00
12 changed files with 393 additions and 33 deletions

View file

@ -3,6 +3,7 @@
## [Non publié]
### Ajouté
- **Bilan — colonne `asset_type` sur les catégories cotées** (route `/balance/accounts`) : les catégories cotées portent maintenant un `asset_type` explicite (`stock` ou `crypto`) qui pilote le routage de PriceFetchControl vers le bon fournisseur, sans heuristique sur le symbole (ex : ETH = Ethan Allen NYSE *et* Ethereum crypto, deux symboles homonymes). La migration v10 ajoute une colonne nullable et backfille les deux catégories cotées seedées (`stock`, `crypto`) avec leur valeur respective ; les lignes cotées custom existantes restent NULL en attendant un futur écran d'édition pour qu'on les renseigne. Le formulaire de création de catégorie (onglet Catégories) affiche désormais un sélecteur de type d'actif quand `kind = priced` et refuse l'enregistrement tant qu'aucune valeur n'est choisie. L'éditeur de snapshot masque le bouton de récupération de prix sur les lignes cotées dont l'`asset_type` est encore NULL — la saisie manuelle reste l'unique chemin sur ces lignes legacy. (#169)
- **Bilan — documentation et ADRs** (`docs/`) : finalise le milestone Bilan avec la passe documentaire. `docs/architecture.md` répertorie désormais les 5 nouvelles tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`), les 7 nouveaux index, les invariants CHECK et FK (CAD seulement, invariants de type, `RESTRICT` sur `transaction_id` pour la reproductibilité Modified Dietz), le découpage 4 sections de `balance.service.ts` (CRUD / snapshots+lignes / rendements+transferts / prix), les 3 hooks scoped par page (`useBalanceAccounts`, `useSnapshotEditor`, `useBalanceOverview`), la commande Tauri `compute_account_return` (avec mention de la future commande `fetch_price` Phase 5), et les 3 nouvelles routes `/balance*`. Trois nouveaux ADRs accompagnent : **0008 — Modified Dietz** (justifie le choix vs ROI / TWR / IRR avec référence à `return_calculator.rs`) ; **0009 — Proxy price-fetching via maximus-api** (architecture documentée maintenant, implémentation BLOQUÉE en attendant la Phase 2 de maximus-api — couvre les considérations privacy comme le strip de headers, l'absence de corrélation `(symbole, licence)` dans les logs et le User-Agent fixe `simpl-resultat`, l'abstraction adapter Yahoo + CoinGecko, la stratégie d'auth Bearer, le rate-limiting client + serveur et le double gating premium UI + serveur) ; **0010 — FK RESTRICT sur `balance_account_transfers.transaction_id`** (justifie l'arbitrage intégrité vs friction pour la reproductibilité Modified Dietz). Le guide utilisateur gagne une nouvelle section *Bilan* qui détaille la saisie de snapshot (simple + coté), la liaison de transferts, la lecture des rendements multi-horizons (3M / 1A / depuis création avec colonne non-ajustée côte à côte), avec la mention « à venir Phase 5 » pour le price-fetching premium. Clés i18n `docs.balance.*` (FR + EN) ajoutées pour que le guide in-app reflète la nouvelle section (#145)
- **Bilan — suite de tests d'intégration cross-cutting** (infrastructure de tests) : clôt la feature *Bilan* avec une couche de tests d'intégration qui exerce toute la surface TypeScript en un seul flux de bout en bout (compte → catégorie cotée → snapshot coté → transfert lié → rendement) et des assertions dédiées sur le verrou de devise (CAD seulement au MVP, refusé à la fois côté service et côté CHECK SQL), la sécurité de tolérance pour le type coté (un mauvais enregistrement ne doit PAS supprimer les lignes existantes), le câblage de `computeAccountReturn` (résolution du profil actif, transmission des dates ISO, conservation telle quelle d'une réponse de période partielle). Trois nouveaux tests Rust d'intégration appliquent la migration v9 par-dessus un schéma v1 seedé contenant déjà des transactions pour vérifier (1) aucune perte ni mutation de données, (2) le round-trip lier / délier sur de vraies `transaction_id`, (3) la chaîne FK RESTRICT (suppression d'une transaction liée bloquée, autorisée après détachement), (4) la cohabitation indépendante des espaces d'identifiants `categories.id` (v1) et `balance_categories.id` (v9). Un test de non-régression au niveau source sur `TransactionTable.tsx` verrouille le contrat de l'icône de transfert inlinée : prop optionnelle, court-circuit en chaînage optionnel, clés i18n, aria-label, layout partagé de la cellule description — pour que la page reste rendue à l'identique en l'absence de transferts liés. (#144)
- **Bilan — rendements Modified Dietz et liaison de transferts** (route `/balance`) : le rendement par compte arrive enfin. Nouveau module Rust `commands/return_calculator.rs` qui implémente la formule Modified Dietz `R = (V_fin V_début ΣCF_i) / (V_début + ΣW_i × CF_i)` avec pondération des apports à la précision du jour `W_i = (T t_i) / T`, et annualisation `(1 + R)^(365/T) 1`. Les cas limites — snapshot d'extrémité manquant, aucun flux taggé sur la période, compte créé en cours de période, vidé puis rechargé, période de durée nulle — sont surfacés via les flags explicites `is_partial` / `has_no_transfers_warning` pour que l'UI affiche un tiret + tooltip clair plutôt qu'un nombre incompréhensible. Nouvelle commande Tauri `compute_account_return(account_id, period_start, period_end)` qui exécute trois lectures SQL courtes contre la BD du profil actif (dernier snapshot ≤ début de période, dernier snapshot ≤ fin de période, transferts joints aux transactions filtrés sur la période) puis alimente le calculateur. Sept tests Rust co-localisés en TDD couvrent chaque cas avant l'implémentation. Le tableau des comptes sur `/balance` affiche désormais quatre colonnes supplémentaires côte à côte : 3M / 1A / Depuis création (Modified Dietz) plus une colonne *Non ajusté* qui calcule simplement `(V_fin V_début) / V_début` pour qu'on voie d'un coup d'œil quelle part du rendement vient de la pondération des apports. Le menu d'actions de chaque ligne reçoit l'item *Lier transferts* qui ouvre une modal de sélection multiple avec filtres période / catégorie / recherche texte ; la modal propose automatiquement le sens (`in` pour les montants bancaires négatifs, `out` pour les positifs) et l'utilisateur peut inverser ligne par ligne avant de soumettre. Les transactions liées à un ou plusieurs comptes de bilan affichent maintenant une petite icône `Link2` à côté de la description dans la page *Transactions*, avec un tooltip listant les noms et sens des comptes. Les chemins de suppression en lot (par fichier importé et tout effacer) pré-vérifient l'existence d'un lien dans `balance_account_transfers` et surfacent l'erreur typée `TransactionLinkedToBalanceError` (« Cette transaction est liée au compte de bilan X — déliez-la avant de supprimer ») au lieu de laisser fuiter l'erreur SQLite brute. Le graphique d'évolution sur `/balance` superpose désormais des lignes verticales de référence à chaque date de transfert lié (vert pour `in`, rouge pour `out`). Nouvelles clés i18n sous `balance.returns.*`, `balance.accountsTable.*`, `balance.transfers.*`, `balance.evolution.*`, `transactions.transferIcon.*` (FR + EN) (#142)

View file

@ -3,6 +3,7 @@
## [Unreleased]
### Added
- **Balance sheet — `asset_type` column on priced categories** (route `/balance/accounts`): priced balance categories now carry an explicit `asset_type` (`stock` or `crypto`) that drives PriceFetchControl provider routing without relying on symbol heuristics (e.g. ETH = Ethan Allen NYSE *and* Ethereum crypto are no longer ambiguous). Migration v10 adds a nullable column and backfills the two seeded priced categories (`stock`, `crypto`) with their matching values; legacy custom priced rows stay NULL until a future edit-category UI lets the user fill them in. The category creation form (Categories tab) now shows an asset-type selector when `kind = priced` and rejects submission until a value is picked. The snapshot editor hides the price-fetch button on priced rows whose `asset_type` is still NULL — manual entry remains the only path on those legacy rows. (#169)
- **Balance sheet — documentation and ADRs** (`docs/`): closes the Bilan milestone with the documentation pass. `docs/architecture.md` now lists the 5 new tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`), the 7 new indexes, the SQL CHECK and FK invariants (CAD-only, kind invariants, `RESTRICT` on `transaction_id` for Modified Dietz reproducibility), the `balance.service.ts` 4-section layout (CRUD / snapshots+lines / returns+transfers / prices), the 3 page-scoped hooks (`useBalanceAccounts`, `useSnapshotEditor`, `useBalanceOverview`), the `compute_account_return` Tauri command (with the `fetch_price` future-Phase-5 mention), and the 3 new `/balance*` routes. Three new ADRs land alongside: **0008 — Modified Dietz** (justifies the choice vs. ROI / TWR / IRR with reference to `return_calculator.rs`); **0009 — Proxy price-fetching via maximus-api** (architecture documented now, implementation stays BLOCKED by maximus-api Phase 2 — covers privacy considerations like header stripping, no `(symbol, license)` log correlation and the fixed `simpl-resultat` UA, the Yahoo + CoinGecko provider abstraction, the Bearer auth strategy, the client + server rate limiting and the dual-side premium gating); **0010 — FK RESTRICT on `balance_account_transfers.transaction_id`** (justifies the integrity over friction trade-off for Modified Dietz reproducibility). The user guide gains a new *Balance sheet* section walking through snapshot entry (simple + priced), transfer linking, multi-horizon return reading (3M / 1Y / since inception with the side-by-side unadjusted column), with the price-fetching premium flagged "coming in Phase 5". `docs.balance.*` i18n keys (FR + EN) ship so the in-app guide reflects the new section (#145)
- **Balance sheet — cross-cutting integration test suite** (test infrastructure): closes out the *Bilan* feature with a layer of integration tests that exercise the whole TypeScript surface in a single happy-path flow (account → priced category → priced snapshot → linked transfer → return) plus dedicated assertions for currency lock (CAD-only at the MVP, rejected at both the service layer and SQL CHECK), priced-kind tolerance safety (a bad save must NOT clear pre-existing lines), `computeAccountReturn` wiring (active-profile resolution, ISO date forwarding, partial-period payload pass-through). Three new Rust integration tests apply migration v9 on top of a seeded v1 schema with pre-existing transactions to verify (1) no row loss / data mutation, (2) link / unlink transfer round-trip on real transaction ids, (3) the FK RESTRICT chain (linked transaction deletion blocked, unblocked after unlink), (4) the v1 `categories.id` and v9 `balance_categories.id` namespaces coexist independently. A non-regression source-level test on `TransactionTable.tsx` locks down the inlined transfer icon contract: optional prop, optional-chaining short-circuit, i18n keys, aria-label, shared description-cell layout — so the page renders identically when no transfers are linked. (#144)
- **Balance sheet — Modified Dietz returns and transfer linking** (route `/balance`): per-account performance now ships. New Rust module `commands/return_calculator.rs` implements the Modified Dietz formula `R = (V_end V_start ΣCF_i) / (V_start + ΣW_i × CF_i)` with day-precision contribution weights `W_i = (T t_i) / T`, plus `(1 + R)^(365/T) 1` annualization. Edge cases — missing endpoint snapshot, no flows tagged in the period, account created mid-period, depleted-then-refilled, zero-length period — are surfaced with explicit `is_partial` / `has_no_transfers_warning` flags so the UI shows a clean dash + tooltip instead of a confusing number. The new Tauri command `compute_account_return(account_id, period_start, period_end)` runs three short SQL reads against the active profile DB (latest snapshot ≤ period start, latest snapshot ≤ period end, transfers JOINed with transactions filtered to the period) and feeds the calculator. Seven co-located TDD tests cover every case before the implementation. The accounts table on `/balance` now shows four extra columns side-by-side: 3M / 1Y / Since-inception (Modified Dietz) plus an *Unadjusted* column showing the simple `(V_end V_start) / V_start` so the user can see at a glance how much of the return came from contribution timing. Each row's actions menu gains a *Link transfers* item that opens a multi-select modal with date range / category / free-text filters; the modal auto-proposes the direction (`in` for negative bank amounts, `out` for positive) and the user can flip it per row before submitting. Transactions linked to one or more balance accounts now show a small `Link2` icon next to the description in the *Transactions* page, with a tooltip listing the account name(s) and direction(s). Bulk transaction-deletion paths (per-imported-file and clear-all) now pre-check for any link in `balance_account_transfers` and surface a typed `TransactionLinkedToBalanceError` ("This transaction is linked to balance account X — unlink it before deleting") instead of leaking the raw SQLite FK error. The evolution chart on `/balance` now overlays vertical reference lines at every linked-transfer date (green for `in`, red for `out`). New i18n keys under `balance.returns.*`, `balance.accountsTable.*`, `balance.transfers.*`, `balance.evolution.*`, `transactions.transferIcon.*` (FR + EN) (#142)

View file

@ -196,7 +196,8 @@ CREATE TABLE IF NOT EXISTS balance_categories (
kind TEXT NOT NULL CHECK(kind IN ('simple','priced')),
sort_order INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
is_seed INTEGER NOT NULL DEFAULT 0
is_seed INTEGER NOT NULL DEFAULT 0,
asset_type TEXT CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto'))
);
CREATE TABLE IF NOT EXISTS balance_accounts (
@ -261,14 +262,14 @@ CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_acco
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id);
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date);
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) VALUES
('cash', 'balance.category.cash', 'simple', 10, 1),
('tfsa', 'balance.category.tfsa', 'simple', 20, 1),
('rrsp', 'balance.category.rrsp', 'simple', 30, 1),
('fund', 'balance.category.fund', 'simple', 40, 1),
('other', 'balance.category.other', 'simple', 50, 1),
('stock', 'balance.category.stock', 'priced', 60, 1),
('crypto', 'balance.category.crypto', 'priced', 70, 1);
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed, asset_type) VALUES
('cash', 'balance.category.cash', 'simple', 10, 1, NULL),
('tfsa', 'balance.category.tfsa', 'simple', 20, 1, NULL),
('rrsp', 'balance.category.rrsp', 'simple', 30, 1, NULL),
('fund', 'balance.category.fund', 'simple', 40, 1, NULL),
('other', 'balance.category.other', 'simple', 50, 1, NULL),
('stock', 'balance.category.stock', 'priced', 60, 1, 'stock'),
('crypto', 'balance.category.crypto', 'priced', 70, 1, 'crypto');
-- Default preferences (new profiles ship with the v1 IPC taxonomy)
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');

View file

@ -108,6 +108,25 @@ pub fn run() {
sql: database::BALANCE_SCHEMA,
kind: MigrationKind::Up,
},
// Migration v10 — additive: a nullable `asset_type` column on
// balance_categories so the priced-kind UI can route between providers
// (best-effort Yahoo for stocks, exchange APIs for crypto). Symbol
// alone is ambiguous (e.g. ETH = Ethan Allen NYSE AND Ethereum crypto).
// ALTER first → adds NULL column, then UPDATE backfills the two
// priced seeds (`stock`, `crypto`) created by v9. Custom priced rows
// keep NULL until the user edits the category — SnapshotLineRow hides
// the fetch button while asset_type is NULL.
Migration {
version: 10,
description: "add asset_type to balance_categories",
sql: "ALTER TABLE balance_categories ADD COLUMN asset_type TEXT \
CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto')); \
UPDATE balance_categories SET asset_type = 'stock' \
WHERE key = 'stock' AND is_seed = 1; \
UPDATE balance_categories SET asset_type = 'crypto' \
WHERE key = 'crypto' AND is_seed = 1;",
kind: MigrationKind::Up,
},
];
tauri::Builder::default()
@ -1041,5 +1060,121 @@ mod tests {
.unwrap();
assert_eq!(v9_key, "mortgage");
}
// =========================================================================
// Migration v10 — add asset_type to balance_categories
// -------------------------------------------------------------------------
// The v10 SQL applies on top of v9 (column add + backfill of the two priced
// seeds). These tests are statement-equivalent to the production migration:
// they execute the same SQL string used in `lib.rs`'s Migration array.
// =========================================================================
/// Production v10 SQL — kept in sync with the Migration { version: 10 }
/// entry above. Tests apply this on top of fresh_db() (which already ran v9).
const V10_SQL: &str = "ALTER TABLE balance_categories ADD COLUMN asset_type TEXT \
CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto')); \
UPDATE balance_categories SET asset_type = 'stock' \
WHERE key = 'stock' AND is_seed = 1; \
UPDATE balance_categories SET asset_type = 'crypto' \
WHERE key = 'crypto' AND is_seed = 1;";
#[test]
fn migration_v10_adds_asset_type_column() {
let conn = fresh_db();
conn.execute_batch(V10_SQL).expect("apply v10");
// pragma_table_info should now list `asset_type` for balance_categories.
let cols: Vec<String> = conn
.prepare("SELECT name FROM pragma_table_info('balance_categories')")
.unwrap()
.query_map([], |row| row.get::<_, String>(0))
.unwrap()
.map(|r| r.unwrap())
.collect();
assert!(
cols.contains(&"asset_type".to_string()),
"asset_type column should exist after v10, got {:?}",
cols
);
}
#[test]
fn migration_v10_backfills_priced_seeds() {
let conn = fresh_db();
conn.execute_batch(V10_SQL).expect("apply v10");
let stock: String = conn
.query_row(
"SELECT asset_type FROM balance_categories WHERE key = 'stock'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(stock, "stock");
let crypto: String = conn
.query_row(
"SELECT asset_type FROM balance_categories WHERE key = 'crypto'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(crypto, "crypto");
// The 5 simple seeds must remain NULL.
for key in &["cash", "tfsa", "rrsp", "fund", "other"] {
let v: Option<String> = conn
.query_row(
"SELECT asset_type FROM balance_categories WHERE key = ?1",
[key],
|r| r.get(0),
)
.unwrap();
assert!(v.is_none(), "simple seed {} should have NULL asset_type", key);
}
}
#[test]
fn migration_v10_leaves_custom_priced_rows_null() {
let conn = fresh_db();
// Insert a custom priced category BEFORE running v10 — the column
// doesn't exist yet, so it has no asset_type cell to populate.
conn.execute(
"INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_seed) \
VALUES ('mining_etf', 'x.mining', 'priced', 100, 0)",
[],
)
.expect("insert custom priced row");
conn.execute_batch(V10_SQL).expect("apply v10");
let v: Option<String> = conn
.query_row(
"SELECT asset_type FROM balance_categories WHERE key = 'mining_etf'",
[],
|r| r.get(0),
)
.unwrap();
assert!(
v.is_none(),
"legacy custom priced rows must stay NULL post-migration (no fetch button until user edits)"
);
}
#[test]
fn migration_v10_check_rejects_invalid_asset_type() {
let conn = fresh_db();
conn.execute_batch(V10_SQL).expect("apply v10");
let res = conn.execute(
"INSERT INTO balance_categories (key, i18n_key, kind, asset_type) \
VALUES ('bogus', 'x', 'priced', 'gold')",
[],
);
assert!(
res.is_err(),
"CHECK should reject asset_type values outside ('stock','crypto')"
);
}
}

View file

@ -147,6 +147,7 @@ describe("integration — Bilan end-to-end happy path", () => {
i18n_key: "balance.category.etf_prov",
kind: "priced",
sort_order: 80,
asset_type: "stock",
});
expect(categoryId).toBe(100);

View file

@ -13,6 +13,7 @@ import { FormEvent, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type {
BalanceAccount,
BalanceAssetType,
BalanceCategory,
BalanceCategoryKind,
} from "../../shared/types";
@ -53,6 +54,8 @@ export interface CategoryFormValues {
key: string;
i18n_key: string;
kind: BalanceCategoryKind;
/** Required when kind === 'priced' (Issue #169). NULL otherwise. */
asset_type: BalanceAssetType | null;
}
interface CategoryVariantProps {
@ -303,17 +306,35 @@ function CategoryVariant({
key: "",
i18n_key: "",
kind: "simple",
asset_type: null,
});
const [touched, setTouched] = useState(false);
const trimmedKey = values.key.trim();
const trimmedLabel = values.i18n_key.trim();
const keyInvalid = touched && trimmedKey.length === 0;
const assetTypeMissing =
touched && values.kind === "priced" && !values.asset_type;
const submitDisabled =
isSaving ||
!trimmedKey ||
(values.kind === "priced" && !values.asset_type);
const handleKindChange = (next: BalanceCategoryKind) => {
// Switching priced → simple resets asset_type so the NULL invariant for
// simple kind is preserved (the service would coerce it anyway).
setValues((prev) => ({
...prev,
kind: next,
asset_type: next === "priced" ? prev.asset_type : null,
}));
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setTouched(true);
if (!trimmedKey) return;
if (values.kind === "priced" && !values.asset_type) return;
// Fall back to the key if no human label was supplied.
const i18nKey = trimmedLabel || trimmedKey;
await onSubmit({
@ -321,6 +342,7 @@ function CategoryVariant({
i18n_key: i18nKey,
kind: values.kind,
sort_order: 100, // user-created categories sort after seeded ones
asset_type: values.kind === "priced" ? values.asset_type : null,
});
};
@ -389,10 +411,7 @@ function CategoryVariant({
id="category-kind"
value={values.kind}
onChange={(e) =>
setValues({
...values,
kind: e.target.value as BalanceCategoryKind,
})
handleKindChange(e.target.value as BalanceCategoryKind)
}
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
>
@ -406,6 +425,49 @@ function CategoryVariant({
</p>
</div>
{values.kind === "priced" && (
<div>
<label
className="block text-sm font-medium mb-1"
htmlFor="category-asset-type"
>
{t("balance.category.assetType.label")}
</label>
<select
id="category-asset-type"
value={values.asset_type ?? ""}
onChange={(e) =>
setValues({
...values,
asset_type:
e.target.value === ""
? null
: (e.target.value as BalanceAssetType),
})
}
onBlur={() => setTouched(true)}
className={`w-full px-3 py-2 rounded-lg border bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] ${
assetTypeMissing
? "border-[var(--negative)]"
: "border-[var(--border)]"
}`}
>
<option value="">{t("balance.category.assetType.required")}</option>
<option value="stock">
{t("balance.category.assetType.stock")}
</option>
<option value="crypto">
{t("balance.category.assetType.crypto")}
</option>
</select>
{assetTypeMissing && (
<p className="mt-1 text-xs text-[var(--negative)]">
{t("balance.category.assetType.required")}
</p>
)}
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
@ -417,7 +479,7 @@ function CategoryVariant({
</button>
<button
type="submit"
disabled={isSaving || !trimmedKey}
disabled={submitDisabled}
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
{t("balance.category.form.create")}

View file

@ -166,15 +166,15 @@ export default function SnapshotLineRow({
<span className="text-xs text-[var(--muted-foreground)] w-10">
{account.currency}
</span>
{/* PriceFetchControl wired next to the unit_price input (Issue #158).
onPriceFetched updates unit_price only; quantity stays as-is.
TODO: asset_type from category schema (see decisions-log.md MEDIUM) */}
{account.symbol && (
{/* PriceFetchControl wired next to the unit_price input.
Hidden when category_asset_type is null (legacy custom priced
rows pre-#169 migration; user must edit the category to set it). */}
{account.symbol && account.category_asset_type && (
<PriceFetchControl
symbol={account.symbol}
date={snapshotDate ?? ""}
categoryKind={account.category_kind as "priced"}
assetType="stock" // TODO: asset_type from category schema
assetType={account.category_asset_type}
onPriceFetched={(price) =>
onUnitPriceChange?.(String(price))
}

View file

@ -1619,6 +1619,12 @@
"simpleOnlyNotice": "Priced categories (stocks, crypto) will be available in a future release.",
"create": "Create category"
},
"assetType": {
"label": "Asset type",
"stock": "Stock",
"crypto": "Crypto",
"required": "Select an asset type"
},
"error": {
"has_accounts": "Cannot delete this category: {{count}} linked account(s) ({{names}}). Archive or move them first."
},

View file

@ -1619,6 +1619,12 @@
"simpleOnlyNotice": "Les catégories cotées (actions, crypto) seront disponibles dans une prochaine version.",
"create": "Créer la catégorie"
},
"assetType": {
"label": "Type d'actif",
"stock": "Action",
"crypto": "Crypto",
"required": "Sélectionne le type d'actif"
},
"error": {
"has_accounts": "Impossible de supprimer cette catégorie : {{count}} compte(s) lié(s) ({{names}}). Archivez ou déplacez-les d'abord."
},

View file

@ -107,8 +107,85 @@ describe("createBalanceCategory", () => {
const params = mockExecute.mock.calls[0][1] as unknown[];
expect(sql).toContain("INSERT INTO balance_categories");
expect(sql).toContain("is_seed");
expect(sql).toMatch(/0\)$/); // is_seed hardcoded to 0
expect(params).toEqual(["ferr", "balance.category.ferr", "simple", 35]);
// is_seed hardcoded to 0; asset_type passed as the 5th param.
expect(sql).toContain("0, $5)");
// simple kind → asset_type coerced to NULL regardless of input.
expect(params).toEqual([
"ferr",
"balance.category.ferr",
"simple",
35,
null,
]);
});
it("rejects priced category without asset_type (#169)", async () => {
await expect(
createBalanceCategory({
key: "mining_etf",
i18n_key: "x.mining",
kind: "priced",
})
).rejects.toMatchObject({
name: "BalanceServiceError",
code: "asset_type_required",
});
expect(mockExecute).not.toHaveBeenCalled();
});
it("rejects an invalid asset_type value (#169)", async () => {
await expect(
createBalanceCategory({
key: "mining_etf",
i18n_key: "x.mining",
kind: "priced",
// @ts-expect-error testing runtime guard
asset_type: "gold",
})
).rejects.toMatchObject({
name: "BalanceServiceError",
code: "asset_type_invalid",
});
expect(mockExecute).not.toHaveBeenCalled();
});
it("inserts a priced category with asset_type='stock' (#169)", async () => {
mockExecute.mockResolvedValueOnce({ lastInsertId: 99, rowsAffected: 1 });
const id = await createBalanceCategory({
key: "tsx",
i18n_key: "x.tsx",
kind: "priced",
asset_type: "stock",
});
expect(id).toBe(99);
const params = mockExecute.mock.calls[0][1] as unknown[];
expect(params[2]).toBe("priced");
expect(params[4]).toBe("stock");
});
it("inserts a priced category with asset_type='crypto' (#169)", async () => {
mockExecute.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 });
await createBalanceCategory({
key: "alts",
i18n_key: "x.alts",
kind: "priced",
asset_type: "crypto",
});
const params = mockExecute.mock.calls[0][1] as unknown[];
expect(params[4]).toBe("crypto");
});
it("forces asset_type to NULL on simple kind even if provided (#169)", async () => {
mockExecute.mockResolvedValueOnce({ lastInsertId: 1, rowsAffected: 1 });
await createBalanceCategory({
key: "savings",
i18n_key: "x.savings",
kind: "simple",
// Service coerces simple kind → asset_type=null regardless of caller.
asset_type: "stock",
});
const params = mockExecute.mock.calls[0][1] as unknown[];
expect(params[4]).toBeNull();
});
});
@ -221,6 +298,13 @@ describe("listBalanceAccounts", () => {
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).not.toContain("archived_at IS NULL");
});
it("threads category_asset_type from the join (#169)", async () => {
mockSelect.mockResolvedValueOnce([]);
await listBalanceAccounts();
const sql = mockSelect.mock.calls[0][0] as string;
expect(sql).toContain("c.asset_type AS category_asset_type");
});
});
describe("createBalanceAccount", () => {

View file

@ -17,6 +17,7 @@ import type {
BalanceAccount,
BalanceAccountTransferWithTransaction,
BalanceAccountWithCategory,
BalanceAssetType,
BalanceCategory,
BalanceCategoryKind,
BalanceSnapshot,
@ -37,6 +38,8 @@ export type BalanceErrorCode =
| "account_not_found"
| "name_required"
| "kind_invalid"
| "asset_type_required"
| "asset_type_invalid"
| "snapshot_date_required"
| "snapshot_date_taken"
| "snapshot_not_found"
@ -69,7 +72,7 @@ export class BalanceServiceError extends Error {
export async function listBalanceCategories(): Promise<BalanceCategory[]> {
const db = await getDb();
return db.select<BalanceCategory[]>(
`SELECT id, key, i18n_key, kind, sort_order, is_active, is_seed
`SELECT id, key, i18n_key, kind, sort_order, is_active, is_seed, asset_type
FROM balance_categories
ORDER BY sort_order, key`
);
@ -80,7 +83,7 @@ export async function getBalanceCategory(
): Promise<BalanceCategory | null> {
const db = await getDb();
const rows = await db.select<BalanceCategory[]>(
`SELECT id, key, i18n_key, kind, sort_order, is_active, is_seed
`SELECT id, key, i18n_key, kind, sort_order, is_active, is_seed, asset_type
FROM balance_categories
WHERE id = $1`,
[id]
@ -93,16 +96,19 @@ export interface CreateBalanceCategoryInput {
i18n_key: string;
kind: BalanceCategoryKind;
sort_order?: number;
/**
* Required when `kind === 'priced'` (Issue #169). Drives PriceFetchControl
* provider routing (best-effort Yahoo for stocks, exchange APIs for crypto).
* For `kind === 'simple'`, the service forces this to NULL regardless of
* the input value.
*/
asset_type?: BalanceAssetType | null;
}
/**
* Create a user-defined balance category. The seed categories are created by
* Migration v9 never call this for seeded keys (UNIQUE will reject the
* insert anyway).
*
* Note (Issue #138): the AccountsPage UI restricts user-created categories to
* `kind = 'simple'`. The service still accepts both because the priced UI
* lands in Issue #140.
*/
export async function createBalanceCategory(
input: CreateBalanceCategoryInput
@ -113,15 +119,17 @@ export async function createBalanceCategory(
if (input.kind !== "simple" && input.kind !== "priced") {
throw new BalanceServiceError("kind_invalid", "Invalid category kind");
}
const assetType = normalizeAssetTypeForKind(input.kind, input.asset_type);
const db = await getDb();
const result = await db.execute(
`INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_active, is_seed)
VALUES ($1, $2, $3, $4, 1, 0)`,
`INSERT INTO balance_categories (key, i18n_key, kind, sort_order, is_active, is_seed, asset_type)
VALUES ($1, $2, $3, $4, 1, 0, $5)`,
[
input.key.trim(),
input.i18n_key.trim(),
input.kind,
input.sort_order ?? 0,
assetType,
]
);
return result.lastInsertId as number;
@ -131,6 +139,12 @@ export interface UpdateBalanceCategoryInput {
i18n_key?: string;
sort_order?: number;
is_active?: boolean;
/**
* Allows backfilling `asset_type` on legacy priced categories created
* before migration v10. The service rejects an explicit `null` when the
* existing kind is priced (would unset a required field).
*/
asset_type?: BalanceAssetType | null;
}
/**
@ -155,14 +169,45 @@ export async function updateBalanceCategory(
input.sort_order !== undefined ? input.sort_order : existing.sort_order;
const isActive =
input.is_active !== undefined ? (input.is_active ? 1 : 0) : existing.is_active ? 1 : 0;
const assetType =
input.asset_type !== undefined
? normalizeAssetTypeForKind(existing.kind, input.asset_type)
: existing.asset_type;
await db.execute(
`UPDATE balance_categories
SET i18n_key = $1, sort_order = $2, is_active = $3
WHERE id = $4`,
[i18n, sortOrder, isActive, id]
SET i18n_key = $1, sort_order = $2, is_active = $3, asset_type = $4
WHERE id = $5`,
[i18n, sortOrder, isActive, assetType, id]
);
}
/**
* Coerce/validate `asset_type` against `kind`:
* - simple always NULL (input is ignored).
* - priced required, must be 'stock' or 'crypto'.
*/
function normalizeAssetTypeForKind(
kind: BalanceCategoryKind,
raw: BalanceAssetType | null | undefined
): BalanceAssetType | null {
if (kind === "simple") {
return null;
}
if (raw === null || raw === undefined) {
throw new BalanceServiceError(
"asset_type_required",
"asset_type is required for priced categories"
);
}
if (raw !== "stock" && raw !== "crypto") {
throw new BalanceServiceError(
"asset_type_invalid",
"asset_type must be 'stock' or 'crypto'"
);
}
return raw;
}
/**
* Delete a user-created category. Refuses to delete:
* - seeded categories (`is_seed = 1`) UI must disable the button;
@ -212,7 +257,8 @@ export async function listBalanceAccounts(options?: {
return db.select<BalanceAccountWithCategory[]>(
`SELECT a.id, a.balance_category_id, a.name, a.symbol, a.currency,
a.notes, a.is_active, a.archived_at, a.created_at, a.updated_at,
c.key AS category_key, c.i18n_key AS category_i18n_key, c.kind AS category_kind
c.key AS category_key, c.i18n_key AS category_i18n_key,
c.kind AS category_kind, c.asset_type AS category_asset_type
FROM balance_accounts a
INNER JOIN balance_categories c ON c.id = a.balance_category_id
${where}

View file

@ -563,6 +563,15 @@ export interface TransactionPageResult {
export type BalanceCategoryKind = "simple" | "priced";
/**
* Asset class for priced categories. Required when `kind === 'priced'` so
* PriceFetchControl can route to the right provider (best-effort Yahoo for
* stocks, exchange APIs for crypto). NULL for simple kind, and for legacy
* priced rows created before migration v10 those are read-only for the
* fetch flow until the user edits the category.
*/
export type BalanceAssetType = "stock" | "crypto";
export const BALANCE_CURRENCY_CAD = "CAD";
export interface BalanceCategory {
@ -577,6 +586,12 @@ export interface BalanceCategory {
is_active: boolean;
/** True when seeded by Migration v9 — cannot be deleted, can be renamed. */
is_seed: boolean;
/**
* Asset class for priced kind ('stock' | 'crypto'). NULL for simple kind
* and for legacy priced rows created before migration v10. The category
* form requires it on creation when kind=priced.
*/
asset_type: BalanceAssetType | null;
}
export interface BalanceAccount {
@ -600,6 +615,8 @@ export interface BalanceAccountWithCategory extends BalanceAccount {
category_key: string;
category_i18n_key: string;
category_kind: BalanceCategoryKind;
/** Mirror of `balance_categories.asset_type` — drives PriceFetchControl. */
category_asset_type: BalanceAssetType | null;
}
// Snapshots — added Issue #146 (Bilan #1b) for the SnapshotEditPage.