feat(balance): account form vehicle field + category rename via custom_label (#203)
All checks were successful
PR Check / rust (pull_request) Successful in 22m29s
PR Check / frontend (pull_request) Successful in 2m23s

- AccountForm: optional vehicle_type dropdown (account mode, 6 fiscal
  envelopes + none) wired into create/update; custom_label field (category
  mode) written to custom_label, never i18n_key.
- AccountsPage rename: writes custom_label, leaves i18n_key intact (fixes
  bug I where renaming clobbered the translation key).
- New shared renderCategoryLabel helper (+ shape adapters) applied at the 5
  sites: AccountsPage, AccountForm, SnapshotEditor, BalanceAccountsTable,
  BalancePage.
- Hide v13-deactivated seeds: useBalanceAccounts passes includeInactive=false
  (keeps #202 behavior-neutral default + tests green).
- BALANCE_VEHICLE_TYPES exported from shared/types as single source of truth
  (service reuses it).
- i18n FR+EN: vehicleType.*, category.form.customLabel*, errors.vehicle_type_invalid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
le king fu 2026-06-01 20:50:40 -04:00
parent cb58bbb31a
commit 344c27ee6d
12 changed files with 316 additions and 44 deletions

View file

@ -16,12 +16,15 @@ import type {
BalanceAssetType,
BalanceCategory,
BalanceCategoryKind,
BalanceVehicleType,
} from "../../shared/types";
import { BALANCE_VEHICLE_TYPES } from "../../shared/types";
import type {
CreateBalanceAccountInput,
CreateBalanceCategoryInput,
UpdateBalanceAccountInput,
} from "../../services/balance.service";
import { renderCategoryLabelFromCategory } from "../../utils/renderCategoryLabel";
// -----------------------------------------------------------------------------
// Account variant types
@ -32,6 +35,8 @@ export interface AccountFormValues {
name: string;
symbol: string;
notes: string;
/** Fiscal envelope; "" means "no envelope" (→ null in the payload). */
vehicle_type: BalanceVehicleType | "";
}
interface AccountVariantProps {
@ -52,7 +57,12 @@ interface AccountVariantProps {
export interface CategoryFormValues {
key: string;
i18n_key: string;
/**
* User-facing label (Issue #203). Written to `custom_label`, never to
* `i18n_key` consistent with the rename flow and the bug-I fix. The
* service derives `i18n_key` from the key for user-created categories.
*/
custom_label: string;
kind: BalanceCategoryKind;
/** Required when kind === 'priced' (Issue #169). NULL otherwise. */
asset_type: BalanceAssetType | null;
@ -77,6 +87,7 @@ function defaultAccountValues(
name: initial.name,
symbol: initial.symbol ?? "",
notes: initial.notes ?? "",
vehicle_type: initial.vehicle_type ?? "",
};
}
// First active category as a sane default
@ -86,6 +97,7 @@ function defaultAccountValues(
name: "",
symbol: "",
notes: "",
vehicle_type: "",
};
}
@ -136,11 +148,16 @@ function AccountVariant({
setTouched(true);
if (!trimmedName) return;
// "" in the select means "no envelope" → null on the wire.
const vehicleType: BalanceVehicleType | null =
values.vehicle_type === "" ? null : values.vehicle_type;
const payload: CreateBalanceAccountInput = {
balance_category_id: values.balance_category_id,
name: trimmedName,
symbol: trimmedSymbol || null,
notes: values.notes.trim() || null,
vehicle_type: vehicleType,
};
if (isEditing) {
@ -149,6 +166,7 @@ function AccountVariant({
name: payload.name,
symbol: payload.symbol,
notes: payload.notes,
vehicle_type: vehicleType,
};
await onSubmit(updatePayload);
} else {
@ -156,9 +174,6 @@ function AccountVariant({
}
};
const renderCategoryLabel = (cat: BalanceCategory) =>
t(cat.i18n_key, { defaultValue: cat.key });
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
@ -181,7 +196,7 @@ function AccountVariant({
) : (
categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{renderCategoryLabel(cat)}
{renderCategoryLabelFromCategory(cat, t)}
</option>
))
)}
@ -251,6 +266,36 @@ function AccountVariant({
/>
</div>
<div>
<label
className="block text-sm font-medium mb-1"
htmlFor="account-vehicle-type"
>
{t("balance.account.form.vehicleType.label")}
</label>
<select
id="account-vehicle-type"
value={values.vehicle_type}
onChange={(e) =>
setValues({
...values,
vehicle_type: e.target.value as BalanceVehicleType | "",
})
}
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)]"
>
<option value="">{t("balance.account.form.vehicleType.none")}</option>
{BALANCE_VEHICLE_TYPES.map((vt) => (
<option key={vt} value={vt}>
{t(`balance.account.form.vehicleType.${vt}`)}
</option>
))}
</select>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
{t("balance.account.form.vehicleType.hint")}
</p>
</div>
<p className="text-xs text-[var(--muted-foreground)]">
{t("balance.account.form.currencyMvpNotice")}
</p>
@ -290,14 +335,14 @@ function CategoryVariant({
const { t } = useTranslation();
const [values, setValues] = useState<CategoryFormValues>({
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({
<div>
<label
className="block text-sm font-medium mb-1"
htmlFor="category-label"
htmlFor="category-custom-label"
>
{t("balance.category.form.label")}
{t("balance.category.form.customLabel")}
</label>
<input
id="category-label"
id="category-custom-label"
type="text"
value={values.i18n_key}
value={values.custom_label}
onChange={(e) =>
setValues({ ...values, i18n_key: e.target.value })
setValues({ ...values, custom_label: e.target.value })
}
placeholder={t("balance.category.form.labelPlaceholder")}
placeholder={t("balance.category.form.customLabelPlaceholder")}
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)]"
autoComplete="off"
/>
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
{t("balance.category.form.customLabelHint")}
</p>
</div>
</div>

View file

@ -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}
</td>
<td className="px-4 py-3 text-[var(--muted-foreground)]">
{t(acc.category_i18n_key, { defaultValue: acc.category_key })}
{renderCategoryLabelFromAccount(acc, t)}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{acc.latest_value !== null ? fmt.format(acc.latest_value) : "—"}

View file

@ -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({
>
<div className="px-4 py-2 bg-[var(--muted)] border-b border-[var(--border)]">
<h3 className="text-sm font-semibold">
{t(category.i18n_key, { defaultValue: category.key })}
{renderCategoryLabelFromCategory(category, t)}
</h3>
</div>
<div className="px-4">

View file

@ -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 } });

View file

@ -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.",

View file

@ -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.",

View file

@ -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() {
</span>
</td>
<td className="px-4 py-2">
{t(acc.category_i18n_key, {
defaultValue: acc.category_key,
})}
{renderCategoryLabelFromAccount(acc, t)}
</td>
<td className="px-4 py-2 text-[var(--muted-foreground)]">
{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)]"

View file

@ -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<string, string> = {};
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;

View file

@ -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.

View file

@ -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 {

View file

@ -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<string, string> = {
"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");
});
});

View file

@ -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
);
}