Merge pull request 'feat(balance): priced-kind support (#140)' (#149) from issue-140-bilan-2 into main
This commit is contained in:
commit
6341aeb74c
12 changed files with 1119 additions and 187 deletions
|
|
@ -3,6 +3,7 @@
|
|||
## [Non publié]
|
||||
|
||||
### Ajouté
|
||||
- **Bilan — type coté (quantité × prix unitaire)** (routes `/balance/accounts` et `/balance/snapshot`) : troisième tranche de la feature *Bilan*. Les catégories exposent désormais un sélecteur de *type* à la création : `simple` (saisie d'un montant direct) ou `coté` (`quantité × prix_unitaire`). Les comptes liés à une catégorie cotée exigent un symbole. L'éditeur de snapshot bascule selon le type de la catégorie du compte : les comptes simples conservent leur unique champ de valeur ; les comptes cotés affichent trois champs — `quantité`, `prix unitaire` (les deux obligatoires) et un champ `valeur` en lecture seule calculé en temps réel à partir de `quantité × prix unitaire` (arrondi à 2 décimales). Une étiquette d'attribution `[Manuel]` apparaît sur chaque ligne cotée ; la future étiquette `[via Maximus le AAAA-MM-JJ]` arrivera avec la récupération automatique des prix. Le bouton *Pré-remplir depuis le précédent* copie maintenant les quantités pour les comptes cotés mais laisse les prix unitaires vides (un prix frais doit être saisi à chaque fois). Le service valide les lignes cotées avant la CHECK SQL : invariants de type (les lignes cotées doivent porter à la fois quantité et prix unitaire ; les lignes simples ne doivent porter ni l'un ni l'autre) et invariant de valeur `|valeur − quantité × prix unitaire| ≤ 0,01` (un centime de tolérance pour absorber les arrondis flottants). La suppression d'une catégorie est désormais mieux guardée : une catégorie liée à un ou plusieurs comptes affiche un bandeau d'erreur listant le nombre et jusqu'à trois noms de comptes pour que l'utilisateur sache exactement lesquels archiver d'abord ; les catégories standard restent protégées côté service avec leur bouton désactivé dans l'interface. Nouvelles clés i18n `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140)
|
||||
- **Bilan — éditeur de snapshot (type simple)** (route `/balance/snapshot`) : deuxième tranche de la feature *Bilan*. La nouvelle page permet de créer ou modifier un snapshot daté de votre patrimoine : choisissez une date (par défaut aujourd'hui), saisissez la valeur de chaque compte actif groupé par catégorie, puis enregistrez. Le mode est piloté par le paramètre `?date=` de l'URL — si un snapshot existe déjà à cette date, la page bascule automatiquement en mode édition (la contrainte UNIQUE sur `balance_snapshots.snapshot_date` garantit un snapshot par jour). La date d'un snapshot existant est immuable : pour la changer, supprimez puis recréez. Un bouton *Pré-remplir depuis le précédent* copie les valeurs du snapshot antérieur le plus récent (comptes simples uniquement — les comptes cotés seront pris en charge quand l'éditeur coté arrivera). Un bouton *Supprimer* affiche une modal de double confirmation qui exige de retaper la date du snapshot avant d'activer l'action destructive. Seules les valeurs de type simple sont acceptées à ce stade (`quantity` et `unit_price` sont laissés `NULL`) ; l'éditeur coté (quantité × prix unitaire + récupération de prix) arrivera dans une prochaine version. Nouveau hook `useSnapshotEditor` (`useReducer` couvrant tout le cycle de vie) et deux nouveaux composants `SnapshotEditor` + `SnapshotLineRow`. i18n FR/EN sous `balance.snapshot.*` (#146)
|
||||
- **Bilan — fondations du schéma et page Comptes** (route `/balance/accounts`) : première tranche de la nouvelle feature *Bilan*. La migration SQL v9 introduit 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) avec 7 index et seede 7 catégories standard — Encaisse, CELI, REER, Fonds commun, Autre (type simple) + Action et Cryptomonnaie (type coté). La colonne `currency` est verrouillée à `CAD` via une contrainte CHECK au MVP — le support multi-devises arrivera plus tard. La nouvelle page expose deux onglets : *Comptes* (CRUD complet sur les comptes de l'utilisateur, archivage soft plutôt que suppression dure pour préserver les snapshots historiques) et *Catégories* (renommer une catégorie, créer des catégories de type simple, supprimer celles créées par l'utilisateur — les catégories standard sont protégées). Couverture i18n FR/EN complète sous `balance.*`. Snapshots, transferts, rendements et price-fetching premium arriveront dans les prochaines issues ; pour l'instant la route est accessible directement par URL (pas encore d'entrée sidebar) (#138)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Balance sheet — priced kind (quantity × unit price)** (routes `/balance/accounts` and `/balance/snapshot`): third slice of the *Bilan* feature. Categories now expose a *kind* selector at creation: `simple` (direct value entry) or `priced` (`quantity × unit_price`). Accounts linked to a priced category require a symbol. The snapshot editor dispatches on the account's category kind: simple accounts keep their single value field, priced accounts get three inputs — `quantity`, `unit_price` (both required) and a read-only `value` field computed live from `quantity × unit_price` (rounded to 2 decimals). A `[Manual]` / `[Manuel]` attribution tag is shown on each priced row; the future `[via Maximus on YYYY-MM-DD]` tag will land with automatic price-fetching. The *Prefill from previous* button now copies quantities for priced accounts but leaves unit prices blank (a fresh price must be entered each time). The service validates priced lines ahead of the SQL CHECK: kind invariants (priced lines must carry both quantity and unit_price; simple lines must carry neither) and a value-match invariant `|value − quantity × unit_price| ≤ 0.01` (one cent tolerance to absorb floating-point drift). Category deletion now blocks earlier and surfaces a richer error: a category linked to one or more accounts shows a dismissable banner listing the count and up to three account names so the user knows exactly which accounts to archive first; seeded categories remain protected at the service layer with their button disabled in the UI. New i18n keys `balance.category.kind.*`, `balance.category.form.kindLabel/kindHint*`, `balance.category.error.has_accounts`, `balance.snapshot.priced.*` (FR + EN) (#140)
|
||||
- **Balance sheet — snapshot editor (simple kind)** (route `/balance/snapshot`): second slice of the *Bilan* feature. The new page lets you create or edit a dated snapshot of your balance: pick a date (defaulting to today), enter the value of each active account grouped by category, and save. The mode is driven by the `?date=` query parameter — when a snapshot already exists at that date the page automatically flips into edit mode (the underlying `balance_snapshots.snapshot_date` UNIQUE constraint guarantees one snapshot per day). The date of an existing snapshot is immutable: to change it, delete the snapshot and create a new one. A *Prefill from previous snapshot* button copies values from the most recent earlier snapshot (simple-kind accounts only — priced accounts will be handled when the priced editor lands in a later release). A *Delete* button surfaces a double-confirmation modal that requires retyping the snapshot date before the destructive action is enabled. Only simple-kind values are accepted at this stage (`quantity` and `unit_price` are kept `NULL`); the priced editor (quantity × unit price + price fetch) ships in a later release. New `useSnapshotEditor` hook (scoped `useReducer` covering the full lifecycle) and two new components `SnapshotEditor` + `SnapshotLineRow`. FR/EN i18n under `balance.snapshot.*` (#146)
|
||||
- **Balance sheet — schema foundation and accounts page** (route `/balance/accounts`): first slice of the upcoming *Bilan* feature. New SQL migration v9 introduces 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) with 7 indexes and seeds 7 standard categories — Cash, TFSA, RRSP, Mutual Fund, Other (simple kind) plus Stock and Crypto (priced kind). The `currency` column is hardcoded to `CAD` via a CHECK constraint at the MVP — multi-currency support will come in a later release. The new accounts page exposes two tabs: *Accounts* (full CRUD over the user's holdings, soft-archive instead of hard delete to preserve historic snapshots) and *Categories* (rename any category, create simple-kind ones, delete user-created ones — seeded categories are protected). The full FR/EN i18n coverage uses keys under `balance.*`. Snapshots, transfers, returns and the price-fetching premium remain to ship in upcoming issues; for now the route is reachable directly via URL (no sidebar entry yet) (#138)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,31 @@
|
|||
// AccountForm — variant=account (Issue #138 / Bilan #1a).
|
||||
// AccountForm — account or category variant.
|
||||
//
|
||||
// The category variant lands in Issue #140 (Bilan #2) when the priced-kind
|
||||
// switch becomes available. For now this component focuses on creating /
|
||||
// editing a `balance_account` record bound to an existing category.
|
||||
// Mode = 'account' (Issue #138 / Bilan #1a): create / edit a balance_account
|
||||
// row bound to an existing category.
|
||||
// Mode = 'category' (Issue #140 / Bilan #2): create a balance_category row
|
||||
// with a kind selector (`simple | priced`).
|
||||
//
|
||||
// Both variants live in the same component because they share the surrounding
|
||||
// wiring (form layout, save / cancel buttons, validation feedback) and only
|
||||
// the input fields differ.
|
||||
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
BalanceAccount,
|
||||
BalanceCategory,
|
||||
BalanceCategoryKind,
|
||||
} from "../../shared/types";
|
||||
import type {
|
||||
CreateBalanceAccountInput,
|
||||
CreateBalanceCategoryInput,
|
||||
UpdateBalanceAccountInput,
|
||||
} from "../../services/balance.service";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Account variant types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export interface AccountFormValues {
|
||||
balance_category_id: number;
|
||||
name: string;
|
||||
|
|
@ -22,7 +33,8 @@ export interface AccountFormValues {
|
|||
notes: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
interface AccountVariantProps {
|
||||
mode: "account";
|
||||
/** When provided, the form is in edit mode; otherwise creation. */
|
||||
initialAccount?: BalanceAccount | null;
|
||||
categories: BalanceCategory[];
|
||||
|
|
@ -33,7 +45,26 @@ interface Props {
|
|||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function defaultValues(
|
||||
// -----------------------------------------------------------------------------
|
||||
// Category variant types (Issue #140)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export interface CategoryFormValues {
|
||||
key: string;
|
||||
i18n_key: string;
|
||||
kind: BalanceCategoryKind;
|
||||
}
|
||||
|
||||
interface CategoryVariantProps {
|
||||
mode: "category";
|
||||
isSaving: boolean;
|
||||
onSubmit: (values: CreateBalanceCategoryInput) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type Props = AccountVariantProps | CategoryVariantProps;
|
||||
|
||||
function defaultAccountValues(
|
||||
initial: BalanceAccount | null | undefined,
|
||||
categories: BalanceCategory[]
|
||||
): AccountFormValues {
|
||||
|
|
@ -55,22 +86,33 @@ function defaultValues(
|
|||
};
|
||||
}
|
||||
|
||||
export default function AccountForm({
|
||||
export default function AccountForm(props: Props) {
|
||||
if (props.mode === "category") {
|
||||
return <CategoryVariant {...props} />;
|
||||
}
|
||||
return <AccountVariant {...props} />;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Account variant
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function AccountVariant({
|
||||
initialAccount,
|
||||
categories,
|
||||
isSaving,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
}: AccountVariantProps) {
|
||||
const { t } = useTranslation();
|
||||
const [values, setValues] = useState<AccountFormValues>(() =>
|
||||
defaultValues(initialAccount, categories)
|
||||
defaultAccountValues(initialAccount, categories)
|
||||
);
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
// Reset form when target account changes (edit different row).
|
||||
useEffect(() => {
|
||||
setValues(defaultValues(initialAccount, categories));
|
||||
setValues(defaultAccountValues(initialAccount, categories));
|
||||
setTouched(false);
|
||||
}, [initialAccount, categories]);
|
||||
|
||||
|
|
@ -80,17 +122,21 @@ export default function AccountForm({
|
|||
);
|
||||
const isPriced = selectedCategory?.kind === "priced";
|
||||
const trimmedName = values.name.trim();
|
||||
const trimmedSymbol = values.symbol.trim();
|
||||
const nameInvalid = touched && trimmedName.length === 0;
|
||||
// Priced categories require a symbol — surfaced as a validation error.
|
||||
const symbolMissingForPriced = touched && isPriced && trimmedSymbol.length === 0;
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setTouched(true);
|
||||
if (!trimmedName) return;
|
||||
if (isPriced && !trimmedSymbol) return;
|
||||
|
||||
const payload: CreateBalanceAccountInput = {
|
||||
balance_category_id: values.balance_category_id,
|
||||
name: trimmedName,
|
||||
symbol: values.symbol.trim() || null,
|
||||
symbol: trimmedSymbol || null,
|
||||
notes: values.notes.trim() || null,
|
||||
};
|
||||
|
||||
|
|
@ -178,14 +224,24 @@ export default function AccountForm({
|
|||
type="text"
|
||||
value={values.symbol}
|
||||
onChange={(e) => setValues({ ...values, symbol: e.target.value })}
|
||||
onBlur={() => setTouched(true)}
|
||||
placeholder={
|
||||
isPriced
|
||||
? t("balance.account.form.symbolPlaceholderPriced")
|
||||
: t("balance.account.form.symbolPlaceholderSimple")
|
||||
}
|
||||
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)]"
|
||||
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)] ${
|
||||
symbolMissingForPriced
|
||||
? "border-[var(--negative)]"
|
||||
: "border-[var(--border)]"
|
||||
}`}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{symbolMissingForPriced && (
|
||||
<p className="mt-1 text-xs text-[var(--negative)]">
|
||||
{t("balance.account.form.symbolRequiredForPriced")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
@ -216,7 +272,12 @@ export default function AccountForm({
|
|||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving || !trimmedName || categories.length === 0}
|
||||
disabled={
|
||||
isSaving ||
|
||||
!trimmedName ||
|
||||
categories.length === 0 ||
|
||||
(isPriced && !trimmedSymbol)
|
||||
}
|
||||
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{isEditing
|
||||
|
|
@ -227,3 +288,141 @@ export default function AccountForm({
|
|||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Category variant (Issue #140)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function CategoryVariant({
|
||||
isSaving,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: CategoryVariantProps) {
|
||||
const { t } = useTranslation();
|
||||
const [values, setValues] = useState<CategoryFormValues>({
|
||||
key: "",
|
||||
i18n_key: "",
|
||||
kind: "simple",
|
||||
});
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
const trimmedKey = values.key.trim();
|
||||
const trimmedLabel = values.i18n_key.trim();
|
||||
const keyInvalid = touched && trimmedKey.length === 0;
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setTouched(true);
|
||||
if (!trimmedKey) return;
|
||||
// Fall back to the key if no human label was supplied.
|
||||
const i18nKey = trimmedLabel || trimmedKey;
|
||||
await onSubmit({
|
||||
key: trimmedKey,
|
||||
i18n_key: i18nKey,
|
||||
kind: values.kind,
|
||||
sort_order: 100, // user-created categories sort after seeded ones
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-1"
|
||||
htmlFor="category-key"
|
||||
>
|
||||
{t("balance.category.form.key")}
|
||||
</label>
|
||||
<input
|
||||
id="category-key"
|
||||
type="text"
|
||||
value={values.key}
|
||||
onChange={(e) =>
|
||||
setValues({ ...values, key: e.target.value })
|
||||
}
|
||||
onBlur={() => setTouched(true)}
|
||||
placeholder={t("balance.category.form.keyPlaceholder")}
|
||||
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)] ${
|
||||
keyInvalid
|
||||
? "border-[var(--negative)]"
|
||||
: "border-[var(--border)]"
|
||||
}`}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
{keyInvalid && (
|
||||
<p className="mt-1 text-xs text-[var(--negative)]">
|
||||
{t("balance.account.form.nameRequired")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-1"
|
||||
htmlFor="category-label"
|
||||
>
|
||||
{t("balance.category.form.label")}
|
||||
</label>
|
||||
<input
|
||||
id="category-label"
|
||||
type="text"
|
||||
value={values.i18n_key}
|
||||
onChange={(e) =>
|
||||
setValues({ ...values, i18n_key: e.target.value })
|
||||
}
|
||||
placeholder={t("balance.category.form.labelPlaceholder")}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-1"
|
||||
htmlFor="category-kind"
|
||||
>
|
||||
{t("balance.category.form.kindLabel")}
|
||||
</label>
|
||||
<select
|
||||
id="category-kind"
|
||||
value={values.kind}
|
||||
onChange={(e) =>
|
||||
setValues({
|
||||
...values,
|
||||
kind: 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)]"
|
||||
>
|
||||
<option value="simple">{t("balance.category.kind.simple")}</option>
|
||||
<option value="priced">{t("balance.category.kind.priced")}</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||
{values.kind === "priced"
|
||||
? t("balance.category.form.kindHintPriced")
|
||||
: t("balance.category.form.kindHintSimple")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] disabled:opacity-50"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving || !trimmedKey}
|
||||
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")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
// SnapshotEditor — groups the active accounts by balance category and
|
||||
// renders one `SnapshotLineRow` per account.
|
||||
//
|
||||
// Issue #146 / Bilan #1b: simple-kind editor only. The priced variant
|
||||
// (quantity x unit_price + price fetch button) is rendered in #140.
|
||||
// Until then, accounts whose category is `priced` still appear here so
|
||||
// the user can enter a manual aggregate value — the storage layer accepts
|
||||
// a simple-kind line for any account regardless of its category kind.
|
||||
// Both `simple` and `priced` variants are dispatched by `account.category_kind`
|
||||
// inside `SnapshotLineRow`. The editor itself only carries the values down
|
||||
// and the change handlers up.
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
|
@ -13,13 +11,19 @@ import type {
|
|||
BalanceAccountWithCategory,
|
||||
BalanceCategory,
|
||||
} from "../../shared/types";
|
||||
import type { PricedEntry } from "../../hooks/useSnapshotEditor";
|
||||
import SnapshotLineRow from "./SnapshotLineRow";
|
||||
|
||||
interface Props {
|
||||
accounts: BalanceAccountWithCategory[];
|
||||
categories: BalanceCategory[];
|
||||
/** account_id → string-typed value (simple kind). */
|
||||
values: Record<number, string>;
|
||||
/** account_id → {quantity, unit_price} strings (priced kind). */
|
||||
pricedValues: Record<number, PricedEntry>;
|
||||
onValueChange: (accountId: number, next: string) => void;
|
||||
onQuantityChange: (accountId: number, next: string) => void;
|
||||
onUnitPriceChange: (accountId: number, next: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -27,7 +31,10 @@ export default function SnapshotEditor({
|
|||
accounts,
|
||||
categories,
|
||||
values,
|
||||
pricedValues,
|
||||
onValueChange,
|
||||
onQuantityChange,
|
||||
onUnitPriceChange,
|
||||
disabled,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -75,15 +82,22 @@ export default function SnapshotEditor({
|
|||
</h3>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
{catAccounts.map((acc) => (
|
||||
<SnapshotLineRow
|
||||
key={acc.id}
|
||||
account={acc}
|
||||
value={values[acc.id] ?? ""}
|
||||
onChange={(next) => onValueChange(acc.id, next)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
{catAccounts.map((acc) => {
|
||||
const priced = pricedValues[acc.id];
|
||||
return (
|
||||
<SnapshotLineRow
|
||||
key={acc.id}
|
||||
account={acc}
|
||||
value={values[acc.id] ?? ""}
|
||||
quantityValue={priced?.quantity ?? ""}
|
||||
unitPriceValue={priced?.unit_price ?? ""}
|
||||
onChange={(next) => onValueChange(acc.id, next)}
|
||||
onQuantityChange={(next) => onQuantityChange(acc.id, next)}
|
||||
onUnitPriceChange={(next) => onUnitPriceChange(acc.id, next)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,53 @@
|
|||
// SnapshotLineRow — single account line inside the snapshot editor.
|
||||
//
|
||||
// Issue #146 / Bilan #1b ships the *simple* variant only: a single value
|
||||
// input keyed by `account_id`. The priced variant (quantity / unit_price /
|
||||
// computed value + price-fetch button) lands in Issue #140 / Bilan #2.
|
||||
// Two variants are dispatched by `account.category_kind`:
|
||||
//
|
||||
// We intentionally keep this component dumb: it receives a string value
|
||||
// from the parent (the editor stores raw strings to preserve partial input
|
||||
// the user is typing) and emits the new string on every change. Numeric
|
||||
// validation happens at save time in `useSnapshotEditor.save`.
|
||||
// - `simple` (Issue #146): a single value input keyed by `account_id`.
|
||||
// - `priced` (Issue #140): three inputs — `quantity`, `unit_price` (both
|
||||
// required), and a read-only `value` field that
|
||||
// renders `quantity * unit_price` live as the
|
||||
// user types. An attribution tag `[Manuel]`
|
||||
// appears next to the row; the `[via Maximus]`
|
||||
// tag will land with Issue #143 (price-fetching).
|
||||
//
|
||||
// We keep this component dumb on purpose: it receives strings from the
|
||||
// parent (the editor stores raw strings to preserve partial input) and
|
||||
// emits new strings on every change. Numeric validation happens at save
|
||||
// time in `useSnapshotEditor.save` against the service's
|
||||
// `validateLineKindInvariants` helper.
|
||||
|
||||
import { ChangeEvent } from "react";
|
||||
import { ChangeEvent, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { BalanceAccountWithCategory } from "../../shared/types";
|
||||
|
||||
interface Props {
|
||||
interface BaseProps {
|
||||
account: BalanceAccountWithCategory;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SimpleProps extends BaseProps {
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
disabled?: boolean;
|
||||
/** Optional priced handlers for callers that wire both at once. */
|
||||
quantityValue?: string;
|
||||
unitPriceValue?: string;
|
||||
onQuantityChange?: (next: string) => void;
|
||||
onUnitPriceChange?: (next: string) => void;
|
||||
}
|
||||
|
||||
type Props = SimpleProps;
|
||||
|
||||
/**
|
||||
* Parse a string like "12.34" or "12,34" into a finite number, or null
|
||||
* if invalid / empty. Used by the priced variant to compute the live
|
||||
* `value` preview.
|
||||
*/
|
||||
function parseDecimal(raw: string): number | null {
|
||||
if (!raw) return null;
|
||||
const trimmed = String(raw).trim().replace(",", ".");
|
||||
if (!trimmed) return null;
|
||||
const n = Number(trimmed);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export default function SnapshotLineRow({
|
||||
|
|
@ -25,9 +55,119 @@ export default function SnapshotLineRow({
|
|||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
quantityValue,
|
||||
unitPriceValue,
|
||||
onQuantityChange,
|
||||
onUnitPriceChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const isPriced = account.category_kind === "priced";
|
||||
|
||||
// Compute the live value preview for priced rows. Returns null when
|
||||
// either input cannot yet be parsed (so we display a placeholder).
|
||||
const computedPricedValue = useMemo(() => {
|
||||
if (!isPriced) return null;
|
||||
const qty = parseDecimal(quantityValue ?? "");
|
||||
const price = parseDecimal(unitPriceValue ?? "");
|
||||
if (qty === null || price === null) return null;
|
||||
return qty * price;
|
||||
}, [isPriced, quantityValue, unitPriceValue]);
|
||||
|
||||
if (isPriced) {
|
||||
const handleQty = (e: ChangeEvent<HTMLInputElement>) =>
|
||||
onQuantityChange?.(e.target.value);
|
||||
const handlePrice = (e: ChangeEvent<HTMLInputElement>) =>
|
||||
onUnitPriceChange?.(e.target.value);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3 py-2 border-b border-[var(--border)] last:border-b-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{account.name}</span>
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-[var(--muted)] text-[var(--muted-foreground)]"
|
||||
title={t("balance.snapshot.priced.attributionManualHint")}
|
||||
>
|
||||
{t("balance.snapshot.priced.attributionManual")}
|
||||
</span>
|
||||
</div>
|
||||
{account.symbol && (
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{account.symbol}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={quantityValue ?? ""}
|
||||
onChange={handleQty}
|
||||
disabled={disabled}
|
||||
placeholder={t("balance.snapshot.priced.quantityPlaceholder")}
|
||||
className="w-24 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
|
||||
aria-label={t("balance.snapshot.priced.quantityLabel", {
|
||||
account: account.name,
|
||||
})}
|
||||
/>
|
||||
<span className="text-[10px] text-[var(--muted-foreground)] text-right">
|
||||
{t("balance.snapshot.priced.quantity")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-[var(--muted-foreground)] hidden sm:inline">
|
||||
×
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={unitPriceValue ?? ""}
|
||||
onChange={handlePrice}
|
||||
disabled={disabled}
|
||||
placeholder={t("balance.snapshot.priced.unitPricePlaceholder")}
|
||||
className="w-28 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
|
||||
aria-label={t("balance.snapshot.priced.unitPriceLabel", {
|
||||
account: account.name,
|
||||
})}
|
||||
/>
|
||||
<span className="text-[10px] text-[var(--muted-foreground)] text-right">
|
||||
{t("balance.snapshot.priced.unitPrice")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-[var(--muted-foreground)] hidden sm:inline">
|
||||
=
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<input
|
||||
type="text"
|
||||
value={
|
||||
computedPricedValue === null
|
||||
? ""
|
||||
: computedPricedValue.toFixed(2)
|
||||
}
|
||||
readOnly
|
||||
disabled
|
||||
placeholder={t("balance.snapshot.priced.computedValuePlaceholder")}
|
||||
className="w-32 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--muted)]/40 text-sm text-right text-[var(--muted-foreground)] focus:outline-none cursor-not-allowed"
|
||||
aria-label={t("balance.snapshot.priced.computedValueLabel", {
|
||||
account: account.name,
|
||||
})}
|
||||
aria-readonly="true"
|
||||
/>
|
||||
<span className="text-[10px] text-[var(--muted-foreground)] text-right">
|
||||
{t("balance.snapshot.priced.computedValue")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--muted-foreground)] w-10">
|
||||
{account.currency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple variant — unchanged from #146.
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,6 +38,12 @@ import {
|
|||
|
||||
export type SnapshotEditorMode = "new" | "edit";
|
||||
|
||||
/** String-typed entry for a priced-kind line being edited. */
|
||||
export interface PricedEntry {
|
||||
quantity: string;
|
||||
unit_price: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
mode: SnapshotEditorMode;
|
||||
/** ISO YYYY-MM-DD; controlled in 'new' mode, frozen in 'edit'. */
|
||||
|
|
@ -49,11 +55,16 @@ interface State {
|
|||
/** Used to group lines by category in the editor view. */
|
||||
categories: BalanceCategory[];
|
||||
/**
|
||||
* Map of account_id → string-typed value. We keep strings to preserve
|
||||
* empty / partial input the user is typing; conversion to number happens
|
||||
* at save time (and at validation when needed).
|
||||
* Map of account_id → string-typed value (simple kind only). We keep
|
||||
* strings to preserve empty / partial input; conversion to number
|
||||
* happens at save time.
|
||||
*/
|
||||
values: Record<number, string>;
|
||||
/**
|
||||
* Map of account_id → string-typed `{quantity, unit_price}` (priced
|
||||
* kind only). Same partial-input guarantee as `values`.
|
||||
*/
|
||||
pricedValues: Record<number, PricedEntry>;
|
||||
/** Snapshot whose values would prefill if the user clicks "Prefill". */
|
||||
previousSnapshot: BalanceSnapshot | null;
|
||||
/** Lines from `previousSnapshot` (loaded lazily when needed). */
|
||||
|
|
@ -78,13 +89,28 @@ type Action =
|
|||
accounts: BalanceAccountWithCategory[];
|
||||
categories: BalanceCategory[];
|
||||
values: Record<number, string>;
|
||||
pricedValues: Record<number, PricedEntry>;
|
||||
previousSnapshot: BalanceSnapshot | null;
|
||||
previousLines: BalanceSnapshotLine[] | null;
|
||||
};
|
||||
}
|
||||
| { type: "SET_DATE"; payload: string }
|
||||
| { type: "SET_VALUE"; payload: { accountId: number; value: string } }
|
||||
| { type: "PREFILL"; payload: Record<number, string> }
|
||||
| {
|
||||
type: "SET_PRICED_FIELD";
|
||||
payload: {
|
||||
accountId: number;
|
||||
field: "quantity" | "unit_price";
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "PREFILL";
|
||||
payload: {
|
||||
values: Record<number, string>;
|
||||
pricedValues: Record<number, PricedEntry>;
|
||||
};
|
||||
}
|
||||
| { type: "RESET" }
|
||||
| { type: "CLEAR_DIRTY" };
|
||||
|
||||
|
|
@ -96,6 +122,7 @@ function initialState(initialDate: string): State {
|
|||
accounts: [],
|
||||
categories: [],
|
||||
values: {},
|
||||
pricedValues: {},
|
||||
previousSnapshot: null,
|
||||
previousLines: null,
|
||||
isLoading: false,
|
||||
|
|
@ -129,6 +156,7 @@ function reducer(state: State, action: Action): State {
|
|||
accounts: action.payload.accounts,
|
||||
categories: action.payload.categories,
|
||||
values: action.payload.values,
|
||||
pricedValues: action.payload.pricedValues,
|
||||
previousSnapshot: action.payload.previousSnapshot,
|
||||
previousLines: action.payload.previousLines,
|
||||
isLoading: false,
|
||||
|
|
@ -148,10 +176,33 @@ function reducer(state: State, action: Action): State {
|
|||
},
|
||||
isDirty: true,
|
||||
};
|
||||
case "SET_PRICED_FIELD": {
|
||||
const existing =
|
||||
state.pricedValues[action.payload.accountId] ?? {
|
||||
quantity: "",
|
||||
unit_price: "",
|
||||
};
|
||||
const next: PricedEntry =
|
||||
action.payload.field === "quantity"
|
||||
? { ...existing, quantity: action.payload.value }
|
||||
: { ...existing, unit_price: action.payload.value };
|
||||
return {
|
||||
...state,
|
||||
pricedValues: {
|
||||
...state.pricedValues,
|
||||
[action.payload.accountId]: next,
|
||||
},
|
||||
isDirty: true,
|
||||
};
|
||||
}
|
||||
case "PREFILL":
|
||||
return {
|
||||
...state,
|
||||
values: { ...state.values, ...action.payload },
|
||||
values: { ...state.values, ...action.payload.values },
|
||||
pricedValues: {
|
||||
...state.pricedValues,
|
||||
...action.payload.pricedValues,
|
||||
},
|
||||
isDirty: true,
|
||||
};
|
||||
case "RESET":
|
||||
|
|
@ -160,6 +211,7 @@ function reducer(state: State, action: Action): State {
|
|||
// Keep the loaded structure (accounts, categories, snapshot) but wipe
|
||||
// user input back to a clean slate sourced from the saved lines.
|
||||
values: {},
|
||||
pricedValues: {},
|
||||
isDirty: true,
|
||||
};
|
||||
case "CLEAR_DIRTY":
|
||||
|
|
@ -222,11 +274,37 @@ export function useSnapshotEditor(options: Options = {}) {
|
|||
const existing = await getSnapshotByDate(targetDate);
|
||||
const isEdit = !!existing;
|
||||
let values: Record<number, string> = {};
|
||||
let pricedValues: Record<number, PricedEntry> = {};
|
||||
let previousLines: BalanceSnapshotLine[] | null = null;
|
||||
// Index account kinds for quick line classification.
|
||||
const kindByAccountId = new Map<number, BalanceCategory["kind"]>();
|
||||
for (const acc of accounts) {
|
||||
kindByAccountId.set(acc.id, acc.category_kind);
|
||||
}
|
||||
if (existing) {
|
||||
const lines = await listLinesBySnapshot(existing.id);
|
||||
for (const line of lines) {
|
||||
values[line.account_id] = String(line.value);
|
||||
// The line itself carries quantity / unit_price for priced kinds;
|
||||
// we still cross-check against the account kind to decide which
|
||||
// input map this row belongs to (it dictates what the user sees).
|
||||
const kind = kindByAccountId.get(line.account_id);
|
||||
if (
|
||||
kind === "priced" ||
|
||||
(line.quantity !== null && line.unit_price !== null)
|
||||
) {
|
||||
pricedValues[line.account_id] = {
|
||||
quantity:
|
||||
line.quantity !== null && line.quantity !== undefined
|
||||
? String(line.quantity)
|
||||
: "",
|
||||
unit_price:
|
||||
line.unit_price !== null && line.unit_price !== undefined
|
||||
? String(line.unit_price)
|
||||
: "",
|
||||
};
|
||||
} else {
|
||||
values[line.account_id] = String(line.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
const previous = await getPreviousSnapshot(targetDate);
|
||||
|
|
@ -243,6 +321,7 @@ export function useSnapshotEditor(options: Options = {}) {
|
|||
accounts,
|
||||
categories,
|
||||
values,
|
||||
pricedValues,
|
||||
previousSnapshot: previous,
|
||||
previousLines,
|
||||
},
|
||||
|
|
@ -269,17 +348,36 @@ export function useSnapshotEditor(options: Options = {}) {
|
|||
});
|
||||
}, []);
|
||||
|
||||
const setLineQuantity = useCallback(
|
||||
(accountId: number, value: string) => {
|
||||
dispatch({
|
||||
type: "SET_PRICED_FIELD",
|
||||
payload: { accountId, field: "quantity", value },
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const setLineUnitPrice = useCallback(
|
||||
(accountId: number, value: string) => {
|
||||
dispatch({
|
||||
type: "SET_PRICED_FIELD",
|
||||
payload: { accountId, field: "unit_price", value },
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
dispatch({ type: "RESET" });
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Build the prefill map from the previous snapshot. Per spec-decisions
|
||||
* row "Bouton Pré-remplir" (Issue 1b decision):
|
||||
* row "Bouton Pré-remplir":
|
||||
* - simple kind → copy value
|
||||
* - priced kind → copy quantity, leave unit_price blank → effectively
|
||||
* no-op at Issue #146 because priced UI ships in #140.
|
||||
* We add a TODO so the priced branch is explicit.
|
||||
* - priced kind → copy quantity, leave unit_price blank (the user
|
||||
* must enter or fetch a fresh price each time).
|
||||
*/
|
||||
const prefillFromPrevious = useCallback(() => {
|
||||
const lines = state.previousLines;
|
||||
|
|
@ -288,18 +386,29 @@ export function useSnapshotEditor(options: Options = {}) {
|
|||
for (const acc of state.accounts) {
|
||||
accountKindById.set(acc.id, acc.category_kind);
|
||||
}
|
||||
const next: Record<number, string> = {};
|
||||
const nextSimple: Record<number, string> = {};
|
||||
const nextPriced: Record<number, PricedEntry> = {};
|
||||
for (const line of lines) {
|
||||
const kind = accountKindById.get(line.account_id);
|
||||
if (!kind) continue; // archived account — skip
|
||||
if (kind === "simple") {
|
||||
next[line.account_id] = String(line.value);
|
||||
nextSimple[line.account_id] = String(line.value);
|
||||
} else {
|
||||
// TODO Issue #140 — implement priced prefill (quantity copy, leave
|
||||
// unit_price blank). For Issue #146 the priced UI does not exist yet.
|
||||
// Priced: copy quantity, leave unit_price blank — quantities don't
|
||||
// change unless the user buys / sells, prices always change.
|
||||
nextPriced[line.account_id] = {
|
||||
quantity:
|
||||
line.quantity !== null && line.quantity !== undefined
|
||||
? String(line.quantity)
|
||||
: "",
|
||||
unit_price: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
dispatch({ type: "PREFILL", payload: next });
|
||||
dispatch({
|
||||
type: "PREFILL",
|
||||
payload: { values: nextSimple, pricedValues: nextPriced },
|
||||
});
|
||||
}, [state.previousLines, state.accounts]);
|
||||
|
||||
/**
|
||||
|
|
@ -326,7 +435,13 @@ export function useSnapshotEditor(options: Options = {}) {
|
|||
snapshot_date: state.snapshotDate,
|
||||
});
|
||||
}
|
||||
const lines = Object.entries(state.values)
|
||||
// Index account kinds for line classification at save time.
|
||||
const kindByAccountId = new Map<number, BalanceCategory["kind"]>();
|
||||
for (const acc of state.accounts) {
|
||||
kindByAccountId.set(acc.id, acc.category_kind);
|
||||
}
|
||||
// Simple-kind lines: drop empty fields, accept any finite number.
|
||||
const simpleLines = Object.entries(state.values)
|
||||
.filter(([, v]) => v !== undefined && String(v).trim().length > 0)
|
||||
.map(([accountIdStr, raw]) => {
|
||||
const accountId = Number(accountIdStr);
|
||||
|
|
@ -338,9 +453,49 @@ export function useSnapshotEditor(options: Options = {}) {
|
|||
`Invalid value for account ${accountId}: "${raw}"`
|
||||
);
|
||||
}
|
||||
return { account_id: accountId, value: num };
|
||||
return {
|
||||
account_id: accountId,
|
||||
value: num,
|
||||
account_kind: "simple" as const,
|
||||
};
|
||||
});
|
||||
await upsertSnapshotLines(snapshotId, lines);
|
||||
// Priced-kind lines: both qty + price required, value computed.
|
||||
const pricedLines = Object.entries(state.pricedValues)
|
||||
.filter(
|
||||
([, entry]) =>
|
||||
entry &&
|
||||
String(entry.quantity ?? "").trim().length > 0 &&
|
||||
String(entry.unit_price ?? "").trim().length > 0
|
||||
)
|
||||
.map(([accountIdStr, entry]) => {
|
||||
const accountId = Number(accountIdStr);
|
||||
const qtyTrim = String(entry.quantity).trim().replace(",", ".");
|
||||
const priceTrim = String(entry.unit_price).trim().replace(",", ".");
|
||||
const qty = Number(qtyTrim);
|
||||
const price = Number(priceTrim);
|
||||
if (!Number.isFinite(qty)) {
|
||||
throw new BalanceServiceError(
|
||||
"snapshot_priced_quantity_required",
|
||||
`Invalid quantity for account ${accountId}: "${entry.quantity}"`
|
||||
);
|
||||
}
|
||||
if (!Number.isFinite(price)) {
|
||||
throw new BalanceServiceError(
|
||||
"snapshot_priced_unit_price_required",
|
||||
`Invalid unit_price for account ${accountId}: "${entry.unit_price}"`
|
||||
);
|
||||
}
|
||||
return {
|
||||
account_id: accountId,
|
||||
account_kind: "priced" as const,
|
||||
quantity: qty,
|
||||
unit_price: price,
|
||||
// value = qty * price; the service re-validates the relation
|
||||
// within PRICED_VALUE_TOLERANCE before persisting.
|
||||
value: qty * price,
|
||||
};
|
||||
});
|
||||
await upsertSnapshotLines(snapshotId, [...simpleLines, ...pricedLines]);
|
||||
dispatch({ type: "CLEAR_DIRTY" });
|
||||
// Reload so 'new' mode flips to 'edit' and the snapshot row is in state.
|
||||
await loadForDate(state.snapshotDate);
|
||||
|
|
@ -356,6 +511,8 @@ export function useSnapshotEditor(options: Options = {}) {
|
|||
state.snapshot,
|
||||
state.snapshotDate,
|
||||
state.values,
|
||||
state.pricedValues,
|
||||
state.accounts,
|
||||
loadForDate,
|
||||
]);
|
||||
|
||||
|
|
@ -377,6 +534,8 @@ export function useSnapshotEditor(options: Options = {}) {
|
|||
state,
|
||||
setDate,
|
||||
setLineValue,
|
||||
setLineQuantity,
|
||||
setLineUnitPrice,
|
||||
reset,
|
||||
prefillFromPrevious,
|
||||
save,
|
||||
|
|
|
|||
|
|
@ -1488,6 +1488,7 @@
|
|||
"nameRequired": "Name is required.",
|
||||
"symbol": "Symbol",
|
||||
"symbolPricedHint": "required for priced categories",
|
||||
"symbolRequiredForPriced": "A symbol is required for priced categories.",
|
||||
"symbolPlaceholderSimple": "Optional",
|
||||
"symbolPlaceholderPriced": "e.g. AAPL, BTC-USD",
|
||||
"notes": "Notes",
|
||||
|
|
@ -1517,7 +1518,8 @@
|
|||
"create": "New category",
|
||||
"renamePrompt": "New label for this category",
|
||||
"deleteConfirm": "Delete this category? This cannot be undone.",
|
||||
"deleteSeedHint": "Standard categories cannot be deleted."
|
||||
"deleteSeedHint": "Standard categories cannot be deleted.",
|
||||
"deleteHasAccountsHint": "This category has {{count}} linked account(s) — archive or move them first."
|
||||
},
|
||||
"form": {
|
||||
"createTitle": "New category",
|
||||
|
|
@ -1525,9 +1527,15 @@
|
|||
"keyPlaceholder": "e.g. lira, prpp",
|
||||
"label": "Label",
|
||||
"labelPlaceholder": "e.g. LIRA, PRPP",
|
||||
"kindLabel": "Category kind",
|
||||
"kindHintSimple": "Direct value entry (e.g. checking-account balance).",
|
||||
"kindHintPriced": "Quantity × unit price entry (e.g. stocks, crypto). Linked accounts will require a symbol.",
|
||||
"simpleOnlyNotice": "Priced categories (stocks, crypto) will be available in a future release.",
|
||||
"create": "Create category"
|
||||
},
|
||||
"error": {
|
||||
"has_accounts": "Cannot delete this category: {{count}} linked account(s) ({{names}}). Archive or move them first."
|
||||
},
|
||||
"cash": "Cash",
|
||||
"tfsa": "TFSA",
|
||||
"rrsp": "RRSP",
|
||||
|
|
@ -1559,6 +1567,19 @@
|
|||
"valuePlaceholder": "0.00",
|
||||
"valueLabel": "Value for {{account}}"
|
||||
},
|
||||
"priced": {
|
||||
"quantity": "Quantity",
|
||||
"quantityLabel": "Quantity for {{account}}",
|
||||
"quantityPlaceholder": "0",
|
||||
"unitPrice": "Unit price",
|
||||
"unitPriceLabel": "Unit price for {{account}}",
|
||||
"unitPricePlaceholder": "0.00",
|
||||
"computedValue": "Value (computed)",
|
||||
"computedValueLabel": "Computed value for {{account}}",
|
||||
"computedValuePlaceholder": "—",
|
||||
"attributionManual": "Manual",
|
||||
"attributionManualHint": "Value entered manually. Automatic price fetching will land in a later release."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete this snapshot?",
|
||||
"body": "This permanently deletes the snapshot dated {{date}} and all its lines. To confirm, retype the date below.",
|
||||
|
|
@ -1578,7 +1599,11 @@
|
|||
"snapshot_date_taken": "A snapshot already exists at that date — edit it instead of creating a new one.",
|
||||
"snapshot_not_found": "Snapshot not found.",
|
||||
"snapshot_value_invalid": "An entered value is not a valid number.",
|
||||
"snapshot_priced_unsupported": "Priced accounts (stocks/crypto) will be supported in a future release."
|
||||
"snapshot_priced_unsupported": "Priced accounts (stocks/crypto) will be supported in a future release.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1488,6 +1488,7 @@
|
|||
"nameRequired": "Le nom est obligatoire.",
|
||||
"symbol": "Symbole",
|
||||
"symbolPricedHint": "obligatoire pour cette catégorie cotée",
|
||||
"symbolRequiredForPriced": "Un symbole est obligatoire pour les catégories cotées.",
|
||||
"symbolPlaceholderSimple": "Optionnel",
|
||||
"symbolPlaceholderPriced": "ex. AAPL, BTC-USD",
|
||||
"notes": "Notes",
|
||||
|
|
@ -1517,7 +1518,8 @@
|
|||
"create": "Nouvelle catégorie",
|
||||
"renamePrompt": "Nouveau libellé pour cette catégorie",
|
||||
"deleteConfirm": "Supprimer cette catégorie ? Cette action est irréversible.",
|
||||
"deleteSeedHint": "Les catégories standard ne peuvent pas être supprimées."
|
||||
"deleteSeedHint": "Les catégories standard ne peuvent pas être supprimées.",
|
||||
"deleteHasAccountsHint": "Cette catégorie a {{count}} compte(s) lié(s) — archivez ou déplacez-les d'abord."
|
||||
},
|
||||
"form": {
|
||||
"createTitle": "Nouvelle catégorie",
|
||||
|
|
@ -1525,9 +1527,15 @@
|
|||
"keyPlaceholder": "ex. ferr, rpdb",
|
||||
"label": "Libellé",
|
||||
"labelPlaceholder": "ex. FERR, RPDB",
|
||||
"kindLabel": "Type de catégorie",
|
||||
"kindHintSimple": "Saisie d'un montant direct (ex: solde de compte courant).",
|
||||
"kindHintPriced": "Saisie d'une quantité × prix unitaire (ex: actions, cryptomonnaies). Un symbole sera obligatoire pour les comptes liés.",
|
||||
"simpleOnlyNotice": "Les catégories cotées (actions, crypto) seront disponibles dans une prochaine version.",
|
||||
"create": "Créer la catégorie"
|
||||
},
|
||||
"error": {
|
||||
"has_accounts": "Impossible de supprimer cette catégorie : {{count}} compte(s) lié(s) ({{names}}). Archivez ou déplacez-les d'abord."
|
||||
},
|
||||
"cash": "Encaisse",
|
||||
"tfsa": "CELI",
|
||||
"rrsp": "REER",
|
||||
|
|
@ -1559,6 +1567,19 @@
|
|||
"valuePlaceholder": "0,00",
|
||||
"valueLabel": "Valeur pour {{account}}"
|
||||
},
|
||||
"priced": {
|
||||
"quantity": "Quantité",
|
||||
"quantityLabel": "Quantité pour {{account}}",
|
||||
"quantityPlaceholder": "0",
|
||||
"unitPrice": "Prix unitaire",
|
||||
"unitPriceLabel": "Prix unitaire pour {{account}}",
|
||||
"unitPricePlaceholder": "0,00",
|
||||
"computedValue": "Valeur (calculée)",
|
||||
"computedValueLabel": "Valeur calculée pour {{account}}",
|
||||
"computedValuePlaceholder": "—",
|
||||
"attributionManual": "Manuel",
|
||||
"attributionManualHint": "Valeur saisie manuellement. La récupération automatique des prix arrivera dans une prochaine version."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Supprimer ce snapshot ?",
|
||||
"body": "Cette action supprime définitivement le snapshot du {{date}} et toutes ses lignes. Pour confirmer, retapez la date ci-dessous.",
|
||||
|
|
@ -1578,7 +1599,11 @@
|
|||
"snapshot_date_taken": "Un snapshot existe déjà à cette date — modifiez-le au lieu d'en créer un nouveau.",
|
||||
"snapshot_not_found": "Snapshot introuvable.",
|
||||
"snapshot_value_invalid": "Une valeur saisie n'est pas un nombre valide.",
|
||||
"snapshot_priced_unsupported": "Les comptes cotés (actions/crypto) seront supportés dans une prochaine version."
|
||||
"snapshot_priced_unsupported": "Les comptes cotés (actions/crypto) seront supportés dans une prochaine version.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import type {
|
|||
} from "../shared/types";
|
||||
import { useBalanceAccounts } from "../hooks/useBalanceAccounts";
|
||||
import AccountForm from "../components/balance/AccountForm";
|
||||
import type { CreateBalanceCategoryInput } from "../services/balance.service";
|
||||
|
||||
type Tab = "accounts" | "categories";
|
||||
|
||||
|
|
@ -43,14 +44,27 @@ export default function AccountsPage() {
|
|||
useState<BalanceAccountWithCategory | null>(null);
|
||||
|
||||
const [showCategoryForm, setShowCategoryForm] = useState(false);
|
||||
const [newCategoryKey, setNewCategoryKey] = useState("");
|
||||
const [newCategoryLabel, setNewCategoryLabel] = useState("");
|
||||
/** Local error string for category deletion guard (count + names of linked accounts). */
|
||||
const [categoryDeleteError, setCategoryDeleteError] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const activeCategories = useMemo(
|
||||
() => state.categories.filter((c) => c.is_active),
|
||||
[state.categories]
|
||||
);
|
||||
|
||||
/** Map category id → array of accounts linked to it (active + archived). */
|
||||
const accountsByCategory = useMemo(() => {
|
||||
const m = new Map<number, BalanceAccountWithCategory[]>();
|
||||
for (const acc of state.accounts) {
|
||||
const list = m.get(acc.balance_category_id) ?? [];
|
||||
list.push(acc);
|
||||
m.set(acc.balance_category_id, list);
|
||||
}
|
||||
return m;
|
||||
}, [state.accounts]);
|
||||
|
||||
const renderCategoryLabel = (cat: BalanceCategory) =>
|
||||
t(cat.i18n_key, { defaultValue: cat.key });
|
||||
|
||||
|
|
@ -76,29 +90,39 @@ export default function AccountsPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleCreateCategory = async () => {
|
||||
const key = newCategoryKey.trim();
|
||||
const label = newCategoryLabel.trim();
|
||||
if (!key) return;
|
||||
// For user-created categories we use the literal label as the i18n_key
|
||||
// fallback — they don't ship in the locale bundle, so renderers default
|
||||
// to this string. (The CategoryCombobox does the same for legacy v2 rows.)
|
||||
const i18nKey = label || key;
|
||||
const handleCategorySubmit = async (input: CreateBalanceCategoryInput) => {
|
||||
try {
|
||||
await addCategory({
|
||||
key,
|
||||
i18n_key: i18nKey,
|
||||
kind: "simple",
|
||||
sort_order: 100, // user-created categories sort after seeded ones
|
||||
});
|
||||
setNewCategoryKey("");
|
||||
setNewCategoryLabel("");
|
||||
await addCategory(input);
|
||||
setShowCategoryForm(false);
|
||||
} catch {
|
||||
// Error already surfaced via state.error
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete-guard for categories. The service refuses to delete a seeded
|
||||
* category or one with linked accounts, but we pre-check at the UI to
|
||||
* surface a richer message that lists the linked-account names.
|
||||
*/
|
||||
const handleDeleteCategory = (cat: BalanceCategory) => {
|
||||
setCategoryDeleteError(null);
|
||||
if (cat.is_seed) return;
|
||||
const linked = accountsByCategory.get(cat.id) ?? [];
|
||||
if (linked.length > 0) {
|
||||
const sample = linked.slice(0, 3).map((a) => a.name).join(", ");
|
||||
const more = linked.length > 3 ? ", …" : "";
|
||||
setCategoryDeleteError(
|
||||
t("balance.category.error.has_accounts", {
|
||||
count: linked.length,
|
||||
names: `${sample}${more}`,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(t("balance.category.actions.deleteConfirm"))) return;
|
||||
removeCategory(cat.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
|
|
@ -174,6 +198,7 @@ export default function AccountsPage() {
|
|||
: t("balance.account.form.createTitle")}
|
||||
</h2>
|
||||
<AccountForm
|
||||
mode="account"
|
||||
initialAccount={editingAccount ?? null}
|
||||
categories={activeCategories}
|
||||
isSaving={state.isSaving}
|
||||
|
|
@ -312,66 +337,25 @@ export default function AccountsPage() {
|
|||
<h2 className="text-lg font-semibold mb-4">
|
||||
{t("balance.category.form.createTitle")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-1"
|
||||
htmlFor="category-key"
|
||||
>
|
||||
{t("balance.category.form.key")}
|
||||
</label>
|
||||
<input
|
||||
id="category-key"
|
||||
type="text"
|
||||
value={newCategoryKey}
|
||||
onChange={(e) => setNewCategoryKey(e.target.value)}
|
||||
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)]"
|
||||
placeholder={t("balance.category.form.keyPlaceholder")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-1"
|
||||
htmlFor="category-label"
|
||||
>
|
||||
{t("balance.category.form.label")}
|
||||
</label>
|
||||
<input
|
||||
id="category-label"
|
||||
type="text"
|
||||
value={newCategoryLabel}
|
||||
onChange={(e) => setNewCategoryLabel(e.target.value)}
|
||||
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)]"
|
||||
placeholder={t("balance.category.form.labelPlaceholder")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-3">
|
||||
{t("balance.category.form.simpleOnlyNotice")}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCategoryForm(false);
|
||||
setNewCategoryKey("");
|
||||
setNewCategoryLabel("");
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateCategory}
|
||||
disabled={state.isSaving || !newCategoryKey.trim()}
|
||||
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")}
|
||||
</button>
|
||||
</div>
|
||||
<AccountForm
|
||||
mode="category"
|
||||
isSaving={state.isSaving}
|
||||
onSubmit={handleCategorySubmit}
|
||||
onCancel={() => setShowCategoryForm(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categoryDeleteError && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20 flex items-start justify-between gap-2">
|
||||
<span>{categoryDeleteError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCategoryDeleteError(null)}
|
||||
className="text-xs underline shrink-0"
|
||||
>
|
||||
{t("common.dismiss", { defaultValue: "OK" })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -437,28 +421,29 @@ export default function AccountsPage() {
|
|||
>
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (cat.is_seed) return;
|
||||
if (
|
||||
window.confirm(
|
||||
t("balance.category.actions.deleteConfirm")
|
||||
)
|
||||
) {
|
||||
removeCategory(cat.id);
|
||||
}
|
||||
}}
|
||||
disabled={cat.is_seed}
|
||||
title={
|
||||
cat.is_seed
|
||||
? t("balance.category.actions.deleteSeedHint")
|
||||
: t("common.delete")
|
||||
}
|
||||
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--negative)] disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
{(() => {
|
||||
const linkedCount =
|
||||
accountsByCategory.get(cat.id)?.length ?? 0;
|
||||
const blocked = cat.is_seed || linkedCount > 0;
|
||||
const titleKey = cat.is_seed
|
||||
? t("balance.category.actions.deleteSeedHint")
|
||||
: linkedCount > 0
|
||||
? t("balance.category.actions.deleteHasAccountsHint", {
|
||||
count: linkedCount,
|
||||
})
|
||||
: t("common.delete");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteCategory(cat)}
|
||||
disabled={blocked}
|
||||
title={titleKey}
|
||||
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--negative)] disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ export default function SnapshotEditPage() {
|
|||
const isEditMode = state.mode === "edit";
|
||||
const canPrefill = !!state.previousSnapshot;
|
||||
|
||||
// Aggregate value (simple kind only — sums all visible numeric inputs).
|
||||
// Aggregate value across simple + priced lines (computed live as the
|
||||
// user types). Priced contribution = quantity × unit_price.
|
||||
const totalValue = useMemo(() => {
|
||||
let total = 0;
|
||||
let hasAny = false;
|
||||
|
|
@ -62,8 +63,19 @@ export default function SnapshotEditPage() {
|
|||
hasAny = true;
|
||||
}
|
||||
}
|
||||
for (const entry of Object.values(state.pricedValues)) {
|
||||
if (!entry) continue;
|
||||
const qty = Number(String(entry.quantity ?? "").trim().replace(",", "."));
|
||||
const price = Number(
|
||||
String(entry.unit_price ?? "").trim().replace(",", ".")
|
||||
);
|
||||
if (Number.isFinite(qty) && Number.isFinite(price)) {
|
||||
total += qty * price;
|
||||
hasAny = true;
|
||||
}
|
||||
}
|
||||
return hasAny ? total : null;
|
||||
}, [state.values]);
|
||||
}, [state.values, state.pricedValues]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
|
|
@ -184,7 +196,10 @@ export default function SnapshotEditPage() {
|
|||
accounts={state.accounts}
|
||||
categories={state.categories}
|
||||
values={state.values}
|
||||
pricedValues={state.pricedValues}
|
||||
onValueChange={editor.setLineValue}
|
||||
onQuantityChange={editor.setLineQuantity}
|
||||
onUnitPriceChange={editor.setLineUnitPrice}
|
||||
disabled={state.isSaving}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import {
|
|||
listLinesBySnapshot,
|
||||
upsertSnapshotLines,
|
||||
getPreviousSnapshot,
|
||||
validateLineKindInvariants,
|
||||
PRICED_VALUE_TOLERANCE,
|
||||
BalanceServiceError,
|
||||
} from "./balance.service";
|
||||
|
||||
|
|
@ -546,3 +548,256 @@ describe("getPreviousSnapshot", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Priced-kind validation (Issue #140 / Bilan #2)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
describe("validateLineKindInvariants — simple kind", () => {
|
||||
it("accepts a clean simple line", () => {
|
||||
expect(() =>
|
||||
validateLineKindInvariants({ account_id: 1, value: 1234.56 })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts simple kind with explicit account_kind = simple", () => {
|
||||
expect(() =>
|
||||
validateLineKindInvariants({
|
||||
account_id: 1,
|
||||
value: 0,
|
||||
account_kind: "simple",
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects a simple line carrying a quantity", () => {
|
||||
expect(() =>
|
||||
validateLineKindInvariants({
|
||||
account_id: 1,
|
||||
value: 100,
|
||||
account_kind: "simple",
|
||||
quantity: 10,
|
||||
})
|
||||
).toThrowError(BalanceServiceError);
|
||||
});
|
||||
|
||||
it("rejects a simple line carrying a unit_price", () => {
|
||||
expect(() =>
|
||||
validateLineKindInvariants({
|
||||
account_id: 1,
|
||||
value: 100,
|
||||
account_kind: "simple",
|
||||
unit_price: 10,
|
||||
})
|
||||
).toThrowError(BalanceServiceError);
|
||||
});
|
||||
|
||||
it("rejects a non-finite value", () => {
|
||||
expect(() =>
|
||||
validateLineKindInvariants({ account_id: 1, value: NaN })
|
||||
).toThrowError(BalanceServiceError);
|
||||
expect(() =>
|
||||
validateLineKindInvariants({ account_id: 1, value: Infinity })
|
||||
).toThrowError(BalanceServiceError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateLineKindInvariants — priced kind", () => {
|
||||
const baseInput = {
|
||||
account_id: 7,
|
||||
account_kind: "priced" as const,
|
||||
};
|
||||
|
||||
it("accepts a clean priced line where value === qty * price", () => {
|
||||
expect(() =>
|
||||
validateLineKindInvariants({
|
||||
...baseInput,
|
||||
quantity: 10,
|
||||
unit_price: 25.5,
|
||||
value: 255,
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects a priced line missing the quantity", () => {
|
||||
expect(() =>
|
||||
validateLineKindInvariants({
|
||||
...baseInput,
|
||||
quantity: null,
|
||||
unit_price: 25.5,
|
||||
value: 255,
|
||||
})
|
||||
).toMatchObject; // sanity, real assertion below
|
||||
expect(() =>
|
||||
validateLineKindInvariants({
|
||||
...baseInput,
|
||||
quantity: null,
|
||||
unit_price: 25.5,
|
||||
value: 255,
|
||||
})
|
||||
).toThrowError(BalanceServiceError);
|
||||
try {
|
||||
validateLineKindInvariants({
|
||||
...baseInput,
|
||||
quantity: null,
|
||||
unit_price: 25.5,
|
||||
value: 255,
|
||||
});
|
||||
} catch (e) {
|
||||
expect((e as BalanceServiceError).code).toBe(
|
||||
"snapshot_priced_quantity_required"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a priced line missing the unit_price", () => {
|
||||
try {
|
||||
validateLineKindInvariants({
|
||||
...baseInput,
|
||||
quantity: 10,
|
||||
unit_price: null,
|
||||
value: 255,
|
||||
});
|
||||
} catch (e) {
|
||||
expect((e as BalanceServiceError).code).toBe(
|
||||
"snapshot_priced_unit_price_required"
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected throw");
|
||||
});
|
||||
|
||||
it("rejects a priced line where value disagrees with qty × price", () => {
|
||||
try {
|
||||
validateLineKindInvariants({
|
||||
...baseInput,
|
||||
quantity: 10,
|
||||
unit_price: 25.5,
|
||||
// off by way more than tolerance — 255.0 expected, 999 saved
|
||||
value: 999,
|
||||
});
|
||||
} catch (e) {
|
||||
expect((e as BalanceServiceError).code).toBe(
|
||||
"snapshot_priced_value_mismatch"
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected throw");
|
||||
});
|
||||
|
||||
it("accepts a priced line within the tolerance ε", () => {
|
||||
// 12.34 × 1.07 = 13.2038 in math, but JS gives 13.2038000000000002.
|
||||
// The drift is well within ε = 0.01.
|
||||
const qty = 12.34;
|
||||
const price = 1.07;
|
||||
expect(() =>
|
||||
validateLineKindInvariants({
|
||||
...baseInput,
|
||||
quantity: qty,
|
||||
unit_price: price,
|
||||
value: 13.2038,
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects a priced line just outside the tolerance ε", () => {
|
||||
// expected = 100, threshold ε = 0.01 → 100.011 fails, 100.005 passes.
|
||||
expect(() =>
|
||||
validateLineKindInvariants({
|
||||
...baseInput,
|
||||
quantity: 10,
|
||||
unit_price: 10,
|
||||
value: 100 + PRICED_VALUE_TOLERANCE * 1.5,
|
||||
})
|
||||
).toThrowError(BalanceServiceError);
|
||||
expect(() =>
|
||||
validateLineKindInvariants({
|
||||
...baseInput,
|
||||
quantity: 10,
|
||||
unit_price: 10,
|
||||
value: 100 + PRICED_VALUE_TOLERANCE * 0.5,
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects priced when quantity is non-finite", () => {
|
||||
expect(() =>
|
||||
validateLineKindInvariants({
|
||||
...baseInput,
|
||||
quantity: NaN,
|
||||
unit_price: 10,
|
||||
value: 100,
|
||||
})
|
||||
).toThrowError(BalanceServiceError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertSnapshotLines — priced kind", () => {
|
||||
it("rejects a priced line where qty × price drifts beyond ε", async () => {
|
||||
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
|
||||
await expect(
|
||||
upsertSnapshotLines(5, [
|
||||
{
|
||||
account_id: 7,
|
||||
account_kind: "priced",
|
||||
quantity: 10,
|
||||
unit_price: 25,
|
||||
value: 999, // wrong on purpose
|
||||
},
|
||||
])
|
||||
).rejects.toMatchObject({ code: "snapshot_priced_value_mismatch" });
|
||||
// No DB mutation when validation fails up-front.
|
||||
expect(mockExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("inserts a priced line with quantity + unit_price + value", async () => {
|
||||
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
|
||||
mockExecute
|
||||
.mockResolvedValueOnce({ rowsAffected: 1 }) // delete
|
||||
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert
|
||||
.mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at
|
||||
await upsertSnapshotLines(5, [
|
||||
{
|
||||
account_id: 7,
|
||||
account_kind: "priced",
|
||||
quantity: 10,
|
||||
unit_price: 25.5,
|
||||
value: 255,
|
||||
},
|
||||
]);
|
||||
const insertSql = mockExecute.mock.calls[1][0] as string;
|
||||
expect(insertSql).toContain("INSERT INTO balance_snapshot_lines");
|
||||
// Priced inserts use parameter placeholders for qty/price (not literal NULLs)
|
||||
expect(insertSql).toMatch(/VALUES\s*\(\s*\$1,\s*\$2,\s*\$3,\s*\$4,\s*\$5/);
|
||||
expect(mockExecute.mock.calls[1][1]).toEqual([5, 7, 10, 25.5, 255]);
|
||||
});
|
||||
|
||||
it("supports a mix of simple + priced lines in the same batch", async () => {
|
||||
mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]);
|
||||
mockExecute
|
||||
.mockResolvedValueOnce({ rowsAffected: 1 }) // delete
|
||||
.mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert simple
|
||||
.mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert priced
|
||||
.mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at
|
||||
await upsertSnapshotLines(5, [
|
||||
{ account_id: 1, value: 1000 },
|
||||
{
|
||||
account_id: 7,
|
||||
account_kind: "priced",
|
||||
quantity: 10,
|
||||
unit_price: 50,
|
||||
value: 500,
|
||||
},
|
||||
]);
|
||||
// Simple insert uses literal NULLs for qty/price
|
||||
expect(mockExecute.mock.calls[1][0] as string).toMatch(
|
||||
/VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/
|
||||
);
|
||||
expect(mockExecute.mock.calls[1][1]).toEqual([5, 1, 1000]);
|
||||
// Priced insert uses placeholders
|
||||
expect(mockExecute.mock.calls[2][0] as string).toMatch(
|
||||
/VALUES\s*\(\s*\$1,\s*\$2,\s*\$3,\s*\$4,\s*\$5/
|
||||
);
|
||||
expect(mockExecute.mock.calls[2][1]).toEqual([5, 7, 10, 50, 500]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,7 +36,11 @@ export type BalanceErrorCode =
|
|||
| "snapshot_date_taken"
|
||||
| "snapshot_not_found"
|
||||
| "snapshot_value_invalid"
|
||||
| "snapshot_priced_unsupported";
|
||||
| "snapshot_priced_unsupported"
|
||||
| "snapshot_priced_quantity_required"
|
||||
| "snapshot_priced_unit_price_required"
|
||||
| "snapshot_priced_value_mismatch"
|
||||
| "snapshot_simple_must_be_scalar";
|
||||
|
||||
export class BalanceServiceError extends Error {
|
||||
readonly code: BalanceErrorCode;
|
||||
|
|
@ -528,26 +532,127 @@ export async function listLinesBySnapshot(
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tolerance ε used by the priced-kind invariant `value === quantity * unit_price`.
|
||||
*
|
||||
* Floating-point multiplication of decimal user input is lossy
|
||||
* (`12.34 * 1.07 === 13.2038000000000002`), and the UI displays `value`
|
||||
* rounded to 2 decimals while keeping quantity / unit_price at full
|
||||
* precision. ε = 0.01 (one cent on the dollar) is generous enough to
|
||||
* absorb that drift but tight enough to catch obvious mistakes (off by
|
||||
* 10×). See decisions-log.md / Issue #140.
|
||||
*/
|
||||
export const PRICED_VALUE_TOLERANCE = 0.01;
|
||||
|
||||
export interface SnapshotLineInput {
|
||||
account_id: number;
|
||||
/**
|
||||
* Simple-kind value. Must be a finite number (>= 0 in practice but the
|
||||
* service accepts any finite — negative values support shorts/loans).
|
||||
* Snapshot value at this date. For priced lines this should match
|
||||
* `quantity * unit_price` within `PRICED_VALUE_TOLERANCE`; the service
|
||||
* validates the relation ahead of the SQL CHECK and surfaces a typed
|
||||
* `snapshot_priced_value_mismatch` error otherwise.
|
||||
*/
|
||||
value: number;
|
||||
/**
|
||||
* Category kind of the underlying account. Defaults to 'simple' to
|
||||
* preserve the #146 callers that don't pass it. Priced lines must
|
||||
* provide both `quantity` and `unit_price`.
|
||||
*/
|
||||
account_kind?: BalanceCategoryKind;
|
||||
/** Required for priced lines, must be NULL for simple. */
|
||||
quantity?: number | null;
|
||||
/** Required for priced lines, must be NULL for simple. */
|
||||
unit_price?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a batch of snapshot lines (simple kind only). Each input row is
|
||||
* inserted or replaced atomically per account; lines for accounts not
|
||||
* present in `lines` are removed from the snapshot. This makes the editor
|
||||
* strictly state-driven — what the user sees is exactly what gets saved.
|
||||
* Pure helper that validates a snapshot line against its account's
|
||||
* category kind. Exposed for unit tests and used by `upsertSnapshotLines`
|
||||
* before any DB mutation happens.
|
||||
*
|
||||
* Validation enforced ahead of time so the SQL CHECK never fires:
|
||||
* - finite numeric value (NaN / +-Infinity rejected with `snapshot_value_invalid`);
|
||||
* - quantity / unit_price always stored as NULL (simple-kind invariant).
|
||||
* Rules:
|
||||
* - simple kind → quantity AND unit_price must be NULL/undefined; value
|
||||
* must be a finite number.
|
||||
* - priced kind → quantity AND unit_price must be finite numbers; value
|
||||
* must equal quantity × unit_price within
|
||||
* `PRICED_VALUE_TOLERANCE`.
|
||||
*
|
||||
* Priced-kind upsert lands in Issue #140 (Bilan #2).
|
||||
* @throws `BalanceServiceError` with a typed code on the first failure.
|
||||
*/
|
||||
export function validateLineKindInvariants(
|
||||
line: SnapshotLineInput,
|
||||
accountKind: BalanceCategoryKind = line.account_kind ?? "simple"
|
||||
): void {
|
||||
if (typeof line.value !== "number" || !Number.isFinite(line.value)) {
|
||||
throw new BalanceServiceError(
|
||||
"snapshot_value_invalid",
|
||||
`Line for account ${line.account_id}: value must be a finite number`
|
||||
);
|
||||
}
|
||||
if (accountKind === "simple") {
|
||||
// Simple-kind: quantity / unit_price must be absent (NULL or undefined).
|
||||
if (line.quantity !== undefined && line.quantity !== null) {
|
||||
throw new BalanceServiceError(
|
||||
"snapshot_simple_must_be_scalar",
|
||||
`Line for account ${line.account_id}: simple-kind line must not carry quantity`
|
||||
);
|
||||
}
|
||||
if (line.unit_price !== undefined && line.unit_price !== null) {
|
||||
throw new BalanceServiceError(
|
||||
"snapshot_simple_must_be_scalar",
|
||||
`Line for account ${line.account_id}: simple-kind line must not carry unit_price`
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Priced-kind: both fields required and finite.
|
||||
if (
|
||||
line.quantity === undefined ||
|
||||
line.quantity === null ||
|
||||
typeof line.quantity !== "number" ||
|
||||
!Number.isFinite(line.quantity)
|
||||
) {
|
||||
throw new BalanceServiceError(
|
||||
"snapshot_priced_quantity_required",
|
||||
`Line for account ${line.account_id}: quantity is required for priced accounts`
|
||||
);
|
||||
}
|
||||
if (
|
||||
line.unit_price === undefined ||
|
||||
line.unit_price === null ||
|
||||
typeof line.unit_price !== "number" ||
|
||||
!Number.isFinite(line.unit_price)
|
||||
) {
|
||||
throw new BalanceServiceError(
|
||||
"snapshot_priced_unit_price_required",
|
||||
`Line for account ${line.account_id}: unit_price is required for priced accounts`
|
||||
);
|
||||
}
|
||||
const expected = line.quantity * line.unit_price;
|
||||
if (Math.abs(expected - line.value) > PRICED_VALUE_TOLERANCE) {
|
||||
throw new BalanceServiceError(
|
||||
"snapshot_priced_value_mismatch",
|
||||
`Line for account ${line.account_id}: value ${line.value} does not match quantity × unit_price (${expected})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a batch of snapshot lines. Each input row is inserted or
|
||||
* replaced atomically per account; lines for accounts not present in
|
||||
* `lines` are removed from the snapshot. This makes the editor strictly
|
||||
* state-driven — what the user sees is exactly what gets saved.
|
||||
*
|
||||
* Validation enforced ahead of time so the SQL CHECK never fires
|
||||
* (`validateLineKindInvariants`):
|
||||
* - simple kind → quantity / unit_price must be NULL; value must be finite.
|
||||
* - priced kind → quantity / unit_price must be finite, and
|
||||
* `value === quantity * unit_price` within
|
||||
* `PRICED_VALUE_TOLERANCE`.
|
||||
*
|
||||
* The default `account_kind = 'simple'` preserves the #146 calling
|
||||
* convention — callers that pre-classify their lines (which the priced
|
||||
* editor in #140 must do) pass `account_kind: 'priced'` explicitly.
|
||||
*/
|
||||
export async function upsertSnapshotLines(
|
||||
snapshotId: number,
|
||||
|
|
@ -562,15 +667,7 @@ export async function upsertSnapshotLines(
|
|||
}
|
||||
// Validate every input up-front before mutating anything.
|
||||
for (const line of lines) {
|
||||
if (
|
||||
typeof line.value !== "number" ||
|
||||
!Number.isFinite(line.value)
|
||||
) {
|
||||
throw new BalanceServiceError(
|
||||
"snapshot_value_invalid",
|
||||
`Line for account ${line.account_id}: value must be a finite number`
|
||||
);
|
||||
}
|
||||
validateLineKindInvariants(line);
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
|
|
@ -582,12 +679,28 @@ export async function upsertSnapshotLines(
|
|||
[snapshotId]
|
||||
);
|
||||
for (const line of lines) {
|
||||
await db.execute(
|
||||
`INSERT INTO balance_snapshot_lines
|
||||
(snapshot_id, account_id, quantity, unit_price, value, price_source)
|
||||
VALUES ($1, $2, NULL, NULL, $3, 'manual')`,
|
||||
[snapshotId, line.account_id, line.value]
|
||||
);
|
||||
const kind = line.account_kind ?? "simple";
|
||||
if (kind === "simple") {
|
||||
await db.execute(
|
||||
`INSERT INTO balance_snapshot_lines
|
||||
(snapshot_id, account_id, quantity, unit_price, value, price_source)
|
||||
VALUES ($1, $2, NULL, NULL, $3, 'manual')`,
|
||||
[snapshotId, line.account_id, line.value]
|
||||
);
|
||||
} else {
|
||||
await db.execute(
|
||||
`INSERT INTO balance_snapshot_lines
|
||||
(snapshot_id, account_id, quantity, unit_price, value, price_source)
|
||||
VALUES ($1, $2, $3, $4, $5, 'manual')`,
|
||||
[
|
||||
snapshotId,
|
||||
line.account_id,
|
||||
line.quantity,
|
||||
line.unit_price,
|
||||
line.value,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
// Bump the parent snapshot's updated_at so list views can sort by recency.
|
||||
await db.execute(
|
||||
|
|
|
|||
Loading…
Reference in a new issue