From db5bffbdcf6969f194d65fe56b98cfeabefbed91 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 14:55:20 -0400 Subject: [PATCH 1/4] feat(balance): add priced-kind validation to service + tests - Export validateLineKindInvariants helper for both 'simple' and 'priced' account kinds; surfaces typed BalanceServiceError codes. - Extend SnapshotLineInput with optional account_kind / quantity / unit_price (default 'simple' to preserve #146 callers). - upsertSnapshotLines now validates kind invariants ahead of the SQL CHECK and persists priced lines with non-NULL qty / unit_price. - Tolerance constant PRICED_VALUE_TOLERANCE = 0.01 absorbs FP drift. - 14 new unit tests covering simple invariants, priced invariants, tolerance edge cases, and mixed batches. Refs #140 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/balance.service.test.ts | 255 +++++++++++++++++++++++++++ src/services/balance.service.ts | 165 ++++++++++++++--- 2 files changed, 394 insertions(+), 26 deletions(-) diff --git a/src/services/balance.service.test.ts b/src/services/balance.service.test.ts index 3e1a361..980415e 100644 --- a/src/services/balance.service.test.ts +++ b/src/services/balance.service.test.ts @@ -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]); + }); +}); diff --git a/src/services/balance.service.ts b/src/services/balance.service.ts index 7bc39bb..a3c0b63 100644 --- a/src/services/balance.service.ts +++ b/src/services/balance.service.ts @@ -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( -- 2.45.2 From 6288a3fe2307cfb528ae1843fc14c936a3850517 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 15:01:38 -0400 Subject: [PATCH 2/4] feat(balance): support priced kind in AccountForm + SnapshotLineRow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AccountForm now exposes a 'category' variant with a kind selector (simple | priced); the legacy 'account' variant is unchanged modulo the new symbol-required-for-priced UI guard. - SnapshotLineRow dispatches on account.category_kind: * simple variant unchanged from #146 * priced variant: quantity + unit_price inputs + read-only computed value rendered live (qty × price, 2 decimals) + [Manuel] attribution tag - useSnapshotEditor extends state with pricedValues map, exposes setLineQuantity / setLineUnitPrice handlers, prefill copies quantity but leaves unit_price blank (per spec-decisions row), save() builds mixed simple+priced batches. - SnapshotEditor + SnapshotEditPage thread the new priced state. - Total line at the top of SnapshotEditPage now sums simple + priced contributions live as the user types. Refs #140 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/balance/AccountForm.tsx | 225 +++++++++++++++++++-- src/components/balance/SnapshotEditor.tsx | 42 ++-- src/components/balance/SnapshotLineRow.tsx | 160 ++++++++++++++- src/hooks/useSnapshotEditor.ts | 195 ++++++++++++++++-- src/pages/SnapshotEditPage.tsx | 19 +- 5 files changed, 584 insertions(+), 57 deletions(-) diff --git a/src/components/balance/AccountForm.tsx b/src/components/balance/AccountForm.tsx index a56efb0..f49c7e4 100644 --- a/src/components/balance/AccountForm.tsx +++ b/src/components/balance/AccountForm.tsx @@ -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; + 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 ; + } + return ; +} + +// ----------------------------------------------------------------------------- +// Account variant +// ----------------------------------------------------------------------------- + +function AccountVariant({ initialAccount, categories, isSaving, onSubmit, onCancel, -}: Props) { +}: AccountVariantProps) { const { t } = useTranslation(); const [values, setValues] = useState(() => - 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 && ( +

+ {t("balance.account.form.symbolRequiredForPriced")} +

+ )}
@@ -216,7 +272,12 @@ export default function AccountForm({ + +
+ + ); +} diff --git a/src/components/balance/SnapshotEditor.tsx b/src/components/balance/SnapshotEditor.tsx index ca269cc..7435dc5 100644 --- a/src/components/balance/SnapshotEditor.tsx +++ b/src/components/balance/SnapshotEditor.tsx @@ -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; + /** account_id → {quantity, unit_price} strings (priced kind). */ + pricedValues: Record; 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({
- {catAccounts.map((acc) => ( - onValueChange(acc.id, next)} - disabled={disabled} - /> - ))} + {catAccounts.map((acc) => { + const priced = pricedValues[acc.id]; + return ( + onValueChange(acc.id, next)} + onQuantityChange={(next) => onQuantityChange(acc.id, next)} + onUnitPriceChange={(next) => onUnitPriceChange(acc.id, next)} + disabled={disabled} + /> + ); + })}
))} diff --git a/src/components/balance/SnapshotLineRow.tsx b/src/components/balance/SnapshotLineRow.tsx index 2418f1a..82a79c3 100644 --- a/src/components/balance/SnapshotLineRow.tsx +++ b/src/components/balance/SnapshotLineRow.tsx @@ -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) => + onQuantityChange?.(e.target.value); + const handlePrice = (e: ChangeEvent) => + onUnitPriceChange?.(e.target.value); + + return ( +
+
+
+ {account.name} + + {t("balance.snapshot.priced.attributionManual")} + +
+ {account.symbol && ( +
+ {account.symbol} +
+ )} +
+
+
+ + + {t("balance.snapshot.priced.quantity")} + +
+ + × + +
+ + + {t("balance.snapshot.priced.unitPrice")} + +
+ + = + +
+ + + {t("balance.snapshot.priced.computedValue")} + +
+ + {account.currency} + +
+
+ ); + } + + // Simple variant — unchanged from #146. const handleChange = (e: ChangeEvent) => { onChange(e.target.value); }; diff --git a/src/hooks/useSnapshotEditor.ts b/src/hooks/useSnapshotEditor.ts index 2d835ef..adbf801 100644 --- a/src/hooks/useSnapshotEditor.ts +++ b/src/hooks/useSnapshotEditor.ts @@ -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; + /** + * Map of account_id → string-typed `{quantity, unit_price}` (priced + * kind only). Same partial-input guarantee as `values`. + */ + pricedValues: Record; /** 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; + pricedValues: Record; previousSnapshot: BalanceSnapshot | null; previousLines: BalanceSnapshotLine[] | null; }; } | { type: "SET_DATE"; payload: string } | { type: "SET_VALUE"; payload: { accountId: number; value: string } } - | { type: "PREFILL"; payload: Record } + | { + type: "SET_PRICED_FIELD"; + payload: { + accountId: number; + field: "quantity" | "unit_price"; + value: string; + }; + } + | { + type: "PREFILL"; + payload: { + values: Record; + pricedValues: Record; + }; + } | { 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 = {}; + let pricedValues: Record = {}; let previousLines: BalanceSnapshotLine[] | null = null; + // Index account kinds for quick line classification. + const kindByAccountId = new Map(); + 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 = {}; + const nextSimple: Record = {}; + const nextPriced: Record = {}; 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(); + 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, diff --git a/src/pages/SnapshotEditPage.tsx b/src/pages/SnapshotEditPage.tsx index 4a8062b..650ec0a 100644 --- a/src/pages/SnapshotEditPage.tsx +++ b/src/pages/SnapshotEditPage.tsx @@ -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} /> )} -- 2.45.2 From 5bc7fe80b1f116a6844291c7b70d191a915d9863 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 15:01:44 -0400 Subject: [PATCH 3/4] feat(balance): improve category deletion UX with linked-accounts message - AccountsPage Categories tab now uses the new AccountForm 'category' variant for creation (with kind selector). - Delete button is disabled when the category has linked accounts; the disabled tooltip surfaces the count. - Clicking the delete button on a category with linked accounts now shows a dismissable error banner listing up to the first 3 names (with ellipsis when more) so the user knows exactly which accounts to archive first. The service-level FK RESTRICT remains the ultimate guard. Refs #140 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/pages/AccountsPage.tsx | 185 +++++++++++++++++-------------------- 1 file changed, 85 insertions(+), 100 deletions(-) diff --git a/src/pages/AccountsPage.tsx b/src/pages/AccountsPage.tsx index 78f6336..cc00613 100644 --- a/src/pages/AccountsPage.tsx +++ b/src/pages/AccountsPage.tsx @@ -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(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( + 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(); + 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 (
@@ -174,6 +198,7 @@ export default function AccountsPage() { : t("balance.account.form.createTitle")} {t("balance.category.form.createTitle")} -
-
- - 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" - /> -
-
- - 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" - /> -
-
-

- {t("balance.category.form.simpleOnlyNotice")} -

-
- - -
+ setShowCategoryForm(false)} + /> +
+ )} + + {categoryDeleteError && ( +
+ {categoryDeleteError} +
)} @@ -437,28 +421,29 @@ export default function AccountsPage() { > - + {(() => { + 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 ( + + ); + })()}
-- 2.45.2 From 80c0a9784171a4d98f86b7bd7b7a8544c871732e Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 15:02:18 -0400 Subject: [PATCH 4/4] feat(balance): i18n + CHANGELOG for priced kind - Adds keys under balance.category.kind, balance.category.form.kindLabel / kindHint*, balance.category.actions.deleteHasAccountsHint, balance.category.error.has_accounts, balance.account.form .symbolRequiredForPriced, balance.snapshot.priced.* (FR + EN). - Extends balance.errors.* with the four new typed codes: snapshot_priced_quantity_required, snapshot_priced_unit_price_required, snapshot_priced_value_mismatch, snapshot_simple_must_be_scalar. - CHANGELOG entries (FR + EN) under [Unreleased]. Refs #140 Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + src/i18n/locales/en.json | 29 +++++++++++++++++++++++++++-- src/i18n/locales/fr.json | 29 +++++++++++++++++++++++++++-- 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index d59f703..0b62a25 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0274106..2e52f51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2f52ba9..52ddd3c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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." } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 2a777ff..3b4004d 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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." } } } -- 2.45.2