@@ -181,7 +196,7 @@ function AccountVariant({
) : (
categories.map((cat) => (
))
)}
@@ -251,6 +266,36 @@ function AccountVariant({
/>
+ ({
key: "",
- i18n_key: "",
+ custom_label: "",
kind: "simple",
asset_type: null,
});
const [touched, setTouched] = useState(false);
const trimmedKey = values.key.trim();
- const trimmedLabel = values.i18n_key.trim();
+ const trimmedLabel = values.custom_label.trim();
const keyInvalid = touched && trimmedKey.length === 0;
const assetTypeMissing =
touched && values.kind === "priced" && !values.asset_type;
@@ -321,14 +366,16 @@ function CategoryVariant({
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;
+ // User-created categories have no bundled translation: anchor `i18n_key`
+ // on the key (stable lookup, no free text) and carry the human label in
+ // `custom_label` — same mechanism as renaming (Issue #203 / bug I).
await onSubmit({
key: trimmedKey,
- i18n_key: i18nKey,
+ i18n_key: `balance.category.${trimmedKey}`,
kind: values.kind,
sort_order: 100, // user-created categories sort after seeded ones
asset_type: values.kind === "priced" ? values.asset_type : null,
+ custom_label: trimmedLabel || null,
});
};
@@ -368,21 +415,24 @@ function CategoryVariant({
diff --git a/src/components/balance/BalanceAccountsTable.tsx b/src/components/balance/BalanceAccountsTable.tsx
index c003337..70addf1 100644
--- a/src/components/balance/BalanceAccountsTable.tsx
+++ b/src/components/balance/BalanceAccountsTable.tsx
@@ -27,6 +27,7 @@ import type {
} from "../../services/balance.service";
import { computeAccountReturn } from "../../services/balance.service";
import type { AccountReturn } from "../../shared/types";
+import { renderCategoryLabelFromAccount } from "../../utils/renderCategoryLabel";
const cadFormatter = (locale: string) =>
new Intl.NumberFormat(locale, {
@@ -287,7 +288,7 @@ export default function BalanceAccountsTable({
) : null}
- {t(acc.category_i18n_key, { defaultValue: acc.category_key })}
+ {renderCategoryLabelFromAccount(acc, t)}
|
{acc.latest_value !== null ? fmt.format(acc.latest_value) : "—"}
diff --git a/src/components/balance/SnapshotEditor.tsx b/src/components/balance/SnapshotEditor.tsx
index 0fb4e05..7c9dbec 100644
--- a/src/components/balance/SnapshotEditor.tsx
+++ b/src/components/balance/SnapshotEditor.tsx
@@ -13,6 +13,7 @@ import type {
} from "../../shared/types";
import type { PricedEntry } from "../../hooks/useSnapshotEditor";
import SnapshotLineRow from "./SnapshotLineRow";
+import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel";
interface Props {
accounts: BalanceAccountWithCategory[];
@@ -81,7 +82,7 @@ export default function SnapshotEditor({
>
- {t(category.i18n_key, { defaultValue: category.key })}
+ {renderCategoryLabelFromCategory(category, t)}
diff --git a/src/hooks/useBalanceAccounts.ts b/src/hooks/useBalanceAccounts.ts
index bfafba9..0700b99 100644
--- a/src/hooks/useBalanceAccounts.ts
+++ b/src/hooks/useBalanceAccounts.ts
@@ -115,7 +115,11 @@ export function useBalanceAccounts() {
try {
const [accounts, categories] = await Promise.all([
listBalanceAccounts({ includeArchived }),
- listBalanceCategories(),
+ // Exclude v13-deactivated ex-envelope seeds (tfsa/rrsp) from both the
+ // account-form category dropdown AND the category-management list
+ // (Issue #203). Existing accounts were re-linked to `other` by v13, so
+ // nothing points at a deactivated seed — filtering is safe.
+ listBalanceCategories({ includeInactive: false }),
]);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_DATA", payload: { accounts, categories } });
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index df49aba..ac32acd 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -1643,7 +1643,18 @@
"notes": "Notes",
"currencyMvpNotice": "At the MVP, all accounts are in CAD. Multi-currency support will land in a later version.",
"save": "Save",
- "create": "Create account"
+ "create": "Create account",
+ "vehicleType": {
+ "label": "Fiscal envelope",
+ "none": "None",
+ "unregistered": "Non-registered",
+ "tfsa": "TFSA",
+ "rrsp": "RRSP",
+ "rrif": "RRIF",
+ "fhsa": "FHSA",
+ "resp": "RESP",
+ "hint": "Tax shelter for this account (TFSA, RRSP…). Optional — the asset class stays the type."
+ }
}
},
"category": {
@@ -1680,7 +1691,10 @@
"kindHintSimple": "Direct value entry (e.g. checking-account balance).",
"kindHintPriced": "Quantity × unit price entry (e.g. stocks, crypto). A symbol is optional for linked accounts (only needed for automatic price fetching).",
"simpleOnlyNotice": "Priced types (stocks, crypto) will be available in a future release.",
- "create": "Create type"
+ "create": "Create type",
+ "customLabel": "Label",
+ "customLabelPlaceholder": "e.g. My RRIF, Travel savings",
+ "customLabelHint": "Display name for this type. Leave blank to use the default label."
},
"assetType": {
"label": "Asset type",
@@ -1759,7 +1773,8 @@
"snapshot_priced_quantity_required": "Quantity is required for priced accounts.",
"snapshot_priced_unit_price_required": "Unit price is required for priced accounts.",
"snapshot_priced_value_mismatch": "The entered value does not match quantity × unit price.",
- "snapshot_simple_must_be_scalar": "A simple value must not carry quantity or price."
+ "snapshot_simple_must_be_scalar": "A simple value must not carry quantity or price.",
+ "vehicle_type_invalid": "Invalid fiscal envelope."
},
"returns": {
"partialTooltip": "Partial return: a snapshot is missing for the selected period.",
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index 7a83aa0..4599fa4 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -1643,7 +1643,18 @@
"notes": "Notes",
"currencyMvpNotice": "Au MVP, tous les comptes sont en CAD. Le support multi-devises arrivera dans une version ultérieure.",
"save": "Enregistrer",
- "create": "Créer le compte"
+ "create": "Créer le compte",
+ "vehicleType": {
+ "label": "Véhicule fiscal",
+ "none": "Aucun",
+ "unregistered": "Non-enregistré",
+ "tfsa": "CELI",
+ "rrsp": "REER",
+ "rrif": "FERR",
+ "fhsa": "CELIAPP",
+ "resp": "REEE",
+ "hint": "Enveloppe fiscale du compte (CELI, REER…). Optionnel — la classe d'actif reste le type."
+ }
}
},
"category": {
@@ -1680,7 +1691,10 @@
"kindHintSimple": "Saisie d'un montant direct (ex: solde de compte courant).",
"kindHintPriced": "Saisie d'une quantité × prix unitaire (ex: actions, cryptomonnaies). Un symbole est optionnel pour les comptes liés (requis seulement pour la récupération automatique des prix).",
"simpleOnlyNotice": "Les types cotés (actions, crypto) seront disponibles dans une prochaine version.",
- "create": "Créer le type"
+ "create": "Créer le type",
+ "customLabel": "Libellé",
+ "customLabelPlaceholder": "ex. Mon FERR, Épargne voyage",
+ "customLabelHint": "Nom affiché pour ce type. Laisser vide pour utiliser le libellé par défaut."
},
"assetType": {
"label": "Type d'actif",
@@ -1759,7 +1773,8 @@
"snapshot_priced_quantity_required": "La quantité est obligatoire pour les comptes cotés.",
"snapshot_priced_unit_price_required": "Le prix unitaire est obligatoire pour les comptes cotés.",
"snapshot_priced_value_mismatch": "La valeur saisie ne correspond pas à quantité × prix unitaire.",
- "snapshot_simple_must_be_scalar": "Une valeur simple ne doit pas comporter de quantité ou de prix."
+ "snapshot_simple_must_be_scalar": "Une valeur simple ne doit pas comporter de quantité ou de prix.",
+ "vehicle_type_invalid": "Véhicule fiscal invalide."
},
"returns": {
"partialTooltip": "Rendement partiel : un snapshot manque pour calculer la performance sur cette période.",
diff --git a/src/pages/AccountsPage.tsx b/src/pages/AccountsPage.tsx
index cc00613..5725463 100644
--- a/src/pages/AccountsPage.tsx
+++ b/src/pages/AccountsPage.tsx
@@ -21,6 +21,10 @@ import type {
import { useBalanceAccounts } from "../hooks/useBalanceAccounts";
import AccountForm from "../components/balance/AccountForm";
import type { CreateBalanceCategoryInput } from "../services/balance.service";
+import {
+ renderCategoryLabelFromAccount,
+ renderCategoryLabelFromCategory,
+} from "../utils/renderCategoryLabel";
type Tab = "accounts" | "categories";
@@ -66,7 +70,7 @@ export default function AccountsPage() {
}, [state.accounts]);
const renderCategoryLabel = (cat: BalanceCategory) =>
- t(cat.i18n_key, { defaultValue: cat.key });
+ renderCategoryLabelFromCategory(cat, t);
const closeAccountForm = () => {
setShowAccountForm(false);
@@ -251,9 +255,7 @@ export default function AccountsPage() {
|
- {t(acc.category_i18n_key, {
- defaultValue: acc.category_key,
- })}
+ {renderCategoryLabelFromAccount(acc, t)}
|
{acc.symbol ?? "—"}
@@ -412,8 +414,14 @@ export default function AccountsPage() {
t("balance.category.actions.renamePrompt"),
renderCategoryLabel(cat)
);
- if (next && next.trim()) {
- editCategory(cat.id, { i18n_key: next.trim() });
+ // Write the human label to custom_label, never to
+ // i18n_key — preserves the bundled translation
+ // (fixes bug I). A blank/empty answer clears the
+ // override and falls back to t(i18n_key).
+ if (next !== null) {
+ editCategory(cat.id, {
+ custom_label: next.trim() || null,
+ });
}
}}
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
diff --git a/src/pages/BalancePage.tsx b/src/pages/BalancePage.tsx
index b184641..fb109e7 100644
--- a/src/pages/BalancePage.tsx
+++ b/src/pages/BalancePage.tsx
@@ -33,6 +33,7 @@ import BalanceAccountsTable from "../components/balance/BalanceAccountsTable";
import LinkTransfersModal from "../components/balance/LinkTransfersModal";
import StarterAccountsModal from "../components/balance/StarterAccountsModal";
import { getPreference, setPreference } from "../services/userPreferenceService";
+import { renderCategoryLabelFromAccount } from "../utils/renderCategoryLabel";
const STARTER_PREF_KEY = "balance_starter_proposed";
@@ -143,9 +144,7 @@ export default function BalancePage() {
const m: Record = {};
for (const a of state.accountsLatest) {
if (!m[a.category_key]) {
- m[a.category_key] = t(a.category_i18n_key, {
- defaultValue: a.category_key,
- });
+ m[a.category_key] = renderCategoryLabelFromAccount(a, t);
}
}
return m;
diff --git a/src/services/balance.service.ts b/src/services/balance.service.ts
index ca78fc2..ecab6fa 100644
--- a/src/services/balance.service.ts
+++ b/src/services/balance.service.ts
@@ -25,7 +25,7 @@ import type {
BalanceTransferDirection,
BalanceVehicleType,
} from "../shared/types";
-import { BALANCE_CURRENCY_CAD } from "../shared/types";
+import { BALANCE_CURRENCY_CAD, BALANCE_VEHICLE_TYPES } from "../shared/types";
// -----------------------------------------------------------------------------
// Errors — typed so the UI can show distinct i18n messages.
@@ -345,15 +345,12 @@ export interface CreateBalanceAccountInput {
vehicle_type?: BalanceVehicleType | null;
}
-/** The six recognized fiscal envelopes. Kept in sync with the SQL CHECK. */
-const VEHICLE_TYPES: readonly BalanceVehicleType[] = [
- "unregistered",
- "tfsa",
- "rrsp",
- "rrif",
- "fhsa",
- "resp",
-];
+/**
+ * The six recognized fiscal envelopes. Re-uses the shared/types const so the
+ * service validation and the account-form dropdown (Issue #203) share one
+ * source of truth. Kept in sync with the SQL CHECK.
+ */
+const VEHICLE_TYPES = BALANCE_VEHICLE_TYPES;
/**
* Validate an optional `vehicle_type` against the fiscal-envelope enum.
diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts
index c2c512f..0caf51b 100644
--- a/src/shared/types/index.ts
+++ b/src/shared/types/index.ts
@@ -588,6 +588,21 @@ export type BalanceVehicleType =
| "fhsa"
| "resp";
+/**
+ * The six recognized fiscal envelopes, in display order. Single source of
+ * truth shared by the account form dropdown (Issue #203) and the service-side
+ * `normalizeVehicleType` validation. Kept in sync with the SQL CHECK on
+ * `balance_accounts.vehicle_type`.
+ */
+export const BALANCE_VEHICLE_TYPES: readonly BalanceVehicleType[] = [
+ "unregistered",
+ "tfsa",
+ "rrsp",
+ "rrif",
+ "fhsa",
+ "resp",
+];
+
export const BALANCE_CURRENCY_CAD = "CAD";
export interface BalanceCategory {
diff --git a/src/utils/renderCategoryLabel.test.ts b/src/utils/renderCategoryLabel.test.ts
new file mode 100644
index 0000000..022dc2e
--- /dev/null
+++ b/src/utils/renderCategoryLabel.test.ts
@@ -0,0 +1,94 @@
+import { describe, it, expect } from "vitest";
+import {
+ renderCategoryLabel,
+ renderCategoryLabelFromCategory,
+ renderCategoryLabelFromAccount,
+} from "./renderCategoryLabel";
+
+// Fake `t`: echoes the key prefixed, but honours defaultValue for unknown keys
+// the way react-i18next does. We model "known" keys explicitly.
+const known: Record = {
+ "balance.category.cash": "Liquidités",
+};
+const t = (key: string, options?: { defaultValue?: string }) =>
+ known[key] ?? options?.defaultValue ?? key;
+
+describe("renderCategoryLabel", () => {
+ it("prefers a non-empty custom_label over the i18n translation", () => {
+ expect(
+ renderCategoryLabel("Mon CELI", "balance.category.cash", "cash", t)
+ ).toBe("Mon CELI");
+ });
+
+ it("trims custom_label and treats whitespace-only as absent", () => {
+ expect(
+ renderCategoryLabel(" ", "balance.category.cash", "cash", t)
+ ).toBe("Liquidités");
+ });
+
+ it("falls back to the i18n translation when custom_label is null", () => {
+ expect(
+ renderCategoryLabel(null, "balance.category.cash", "cash", t)
+ ).toBe("Liquidités");
+ });
+
+ it("falls back to the i18n translation when custom_label is undefined", () => {
+ expect(
+ renderCategoryLabel(undefined, "balance.category.cash", "cash", t)
+ ).toBe("Liquidités");
+ });
+
+ it("falls back to the raw key when no translation exists", () => {
+ expect(
+ renderCategoryLabel(null, "balance.category.unknown", "unknown", t)
+ ).toBe("unknown");
+ });
+
+ it("returns a trimmed custom_label (leading/trailing spaces removed)", () => {
+ expect(
+ renderCategoryLabel(" Épargne ", "balance.category.cash", "cash", t)
+ ).toBe("Épargne");
+ });
+});
+
+describe("renderCategoryLabelFromCategory", () => {
+ it("maps the BalanceCategory shape", () => {
+ expect(
+ renderCategoryLabelFromCategory(
+ { custom_label: null, i18n_key: "balance.category.cash", key: "cash" },
+ t
+ )
+ ).toBe("Liquidités");
+ expect(
+ renderCategoryLabelFromCategory(
+ { custom_label: "X", i18n_key: "balance.category.cash", key: "cash" },
+ t
+ )
+ ).toBe("X");
+ });
+});
+
+describe("renderCategoryLabelFromAccount", () => {
+ it("maps the JOIN-prefixed account shape", () => {
+ expect(
+ renderCategoryLabelFromAccount(
+ {
+ category_custom_label: null,
+ category_i18n_key: "balance.category.cash",
+ category_key: "cash",
+ },
+ t
+ )
+ ).toBe("Liquidités");
+ expect(
+ renderCategoryLabelFromAccount(
+ {
+ category_custom_label: "Compte X",
+ category_i18n_key: "balance.category.cash",
+ category_key: "cash",
+ },
+ t
+ )
+ ).toBe("Compte X");
+ });
+});
diff --git a/src/utils/renderCategoryLabel.ts b/src/utils/renderCategoryLabel.ts
new file mode 100644
index 0000000..9b86c62
--- /dev/null
+++ b/src/utils/renderCategoryLabel.ts
@@ -0,0 +1,73 @@
+// renderCategoryLabel — single source of truth for how a balance category's
+// label is shown in the UI (Bilan axe véhicule, Étape 1 / Issue #203).
+//
+// Precedence: an explicit `custom_label` (trimmed, non-empty) always wins;
+// otherwise fall back to the i18n translation of `i18n_key`, and finally to
+// the raw `key` if no translation exists. This is what makes category
+// renaming (which now writes `custom_label`, never `i18n_key`) coexist with
+// the seeded translations and fixes bug I.
+//
+// The 5 call sites carry the same three scalars under DIFFERENT field names:
+// - BalanceCategory → custom_label / i18n_key / key
+// - BalanceAccountWithCategory → category_custom_label / category_i18n_key / category_key
+// - AccountLatestSnapshot → category_custom_label / category_i18n_key / category_key
+// To avoid a shape mismatch (the "affichage partiel" caveat), the core helper
+// takes explicit scalar args; thin adapters normalize each object shape.
+
+/** Minimal i18n translate signature (matches react-i18next's `t`). */
+type TranslateFn = (
+ key: string,
+ options?: { defaultValue?: string }
+) => string;
+
+/**
+ * Resolve a category's display label from its three label scalars.
+ *
+ * @param customLabel User-facing override (`custom_label`). NULL/blank ignored.
+ * @param i18nKey Translation key (`i18n_key`), e.g. 'balance.category.cash'.
+ * @param key Stable lookup key (`key`), used as the last-resort label.
+ * @param t Translate function.
+ */
+export function renderCategoryLabel(
+ customLabel: string | null | undefined,
+ i18nKey: string,
+ key: string,
+ t: TranslateFn
+): string {
+ const trimmed = customLabel?.trim();
+ if (trimmed) return trimmed;
+ return t(i18nKey, { defaultValue: key });
+}
+
+/** Adapter for a `BalanceCategory`-shaped object. */
+export function renderCategoryLabelFromCategory(
+ cat: {
+ custom_label?: string | null;
+ i18n_key: string;
+ key: string;
+ },
+ t: TranslateFn
+): string {
+ return renderCategoryLabel(cat.custom_label, cat.i18n_key, cat.key, t);
+}
+
+/**
+ * Adapter for the JOIN-prefixed shape carried by `BalanceAccountWithCategory`
+ * and `AccountLatestSnapshot` (`category_custom_label` / `category_i18n_key` /
+ * `category_key`).
+ */
+export function renderCategoryLabelFromAccount(
+ acc: {
+ category_custom_label?: string | null;
+ category_i18n_key: string;
+ category_key: string;
+ },
+ t: TranslateFn
+): string {
+ return renderCategoryLabel(
+ acc.category_custom_label,
+ acc.category_i18n_key,
+ acc.category_key,
+ t
+ );
+}
|