Compare commits
No commits in common. "main" and "issue-180-postcss-audit-fix" have entirely different histories.
main
...
issue-180-
20 changed files with 120 additions and 459 deletions
|
|
@ -2,15 +2,12 @@
|
||||||
|
|
||||||
## [Non publié]
|
## [Non publié]
|
||||||
|
|
||||||
## [0.9.1] - 2026-05-10
|
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
||||||
- Bilan : 4 comptes de départ (Compte chèque, CELI, REER, Compte non-enregistré) seedés pour les nouveaux profils, plus un modal d'opt-in unique pour les profils existants à leur première visite de /balance. Cases cochées par défaut ; les comptes existants avec le même nom + catégorie désactivent la ligne correspondante avec un tooltip « Déjà présent ». Confirmation ou ignorance enregistrée dans `user_preferences.balance_starter_proposed` pour que le modal ne réapparaisse jamais. ADR 0012 (Proposed) capture le futur modèle à deux niveaux véhicule × composition (#179).
|
- Bilan : 4 comptes de départ (Compte chèque, CELI, REER, Compte non-enregistré) seedés pour les nouveaux profils, plus un modal d'opt-in unique pour les profils existants à leur première visite de /balance. Cases cochées par défaut ; les comptes existants avec le même nom + catégorie désactivent la ligne correspondante avec un tooltip « Déjà présent ». Confirmation ou ignorance enregistrée dans `user_preferences.balance_starter_proposed` pour que le modal ne réapparaisse jamais. ADR 0012 (Proposed) capture le futur modèle à deux niveaux véhicule × composition (#179).
|
||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
|
|
||||||
- **Récupération de prix activée** — `/v1/prices` de `maximus-api` est en production depuis le 2026-05-05. La fonctionnalité premium de récupération de prix livrée en 0.9.0 (#160) est désormais fonctionnellement disponible de bout en bout (#161).
|
|
||||||
- **Paramètres réorganisés en 3 sous-pages** — la page unique de 12 cartes est éclatée en un hub (`/settings`) qui pointe vers trois sous-pages thématiques : `/settings/users` (comptes, licences, guide d'utilisation), `/settings/data` (catégories, sauvegarde, confidentialité de la récupération de prix) et `/settings/systems` (version, mise à jour, historique des versions, journaux + commentaires). Le guide d'utilisation et l'historique des versions, qui occupaient leurs propres pages, sont maintenant intégrés dans leur sous-page parente ; les anciennes URL `/docs` et `/changelog` redirigent automatiquement pour préserver les marque-pages externes et les liens des notes de version. Le bandeau de sécurité du fallback token-store est maintenant rendu une seule fois en haut du layout des paramètres, visible depuis chaque sous-page principale (#190).
|
- **Paramètres réorganisés en 3 sous-pages** — la page unique de 12 cartes est éclatée en un hub (`/settings`) qui pointe vers trois sous-pages thématiques : `/settings/users` (comptes, licences, guide d'utilisation), `/settings/data` (catégories, sauvegarde, confidentialité de la récupération de prix) et `/settings/systems` (version, mise à jour, historique des versions, journaux + commentaires). Le guide d'utilisation et l'historique des versions, qui occupaient leurs propres pages, sont maintenant intégrés dans leur sous-page parente ; les anciennes URL `/docs` et `/changelog` redirigent automatiquement pour préserver les marque-pages externes et les liens des notes de version. Le bandeau de sécurité du fallback token-store est maintenant rendu une seule fois en haut du layout des paramètres, visible depuis chaque sous-page principale (#190).
|
||||||
- **Icône de l'application** — remplacement de l'icône par défaut de Tauri par un design sur mesure : une calculatrice au visage de robot souriant avec un cadenas de confidentialité sur la touche Entrée / `=`. Reflète les quatre valeurs du produit — robot (assistant), simplicité (formes géométriques), comptabilité (calculatrice), confidentialité (cadenas). Le SVG source est conservé dans `src-tauri/icons/icon.svg` pour les futures itérations ; les 16 fichiers raster spécifiques aux plateformes ont été régénérés via `tauri icon`. Le favicon web et le `<title>` de la fenêtre sont mis à jour aussi (auparavant *« Tauri + React + Typescript »* hérité du scaffolding par défaut).
|
- **Icône de l'application** — remplacement de l'icône par défaut de Tauri par un design sur mesure : une calculatrice au visage de robot souriant avec un cadenas de confidentialité sur la touche Entrée / `=`. Reflète les quatre valeurs du produit — robot (assistant), simplicité (formes géométriques), comptabilité (calculatrice), confidentialité (cadenas). Le SVG source est conservé dans `src-tauri/icons/icon.svg` pour les futures itérations ; les 16 fichiers raster spécifiques aux plateformes ont été régénérés via `tauri icon`. Le favicon web et le `<title>` de la fenêtre sont mis à jour aussi (auparavant *« Tauri + React + Typescript »* hérité du scaffolding par défaut).
|
||||||
- Bilan : remplacement de l'état vide de /balance par une carte d'onboarding à 2 étapes (Créer un compte → Saisir un snapshot) pour éviter l'écran « aucun snapshot » déroutant avant qu'un compte n'existe. Le bouton « + Nouveau snapshot » est masqué tant qu'aucun compte n'existe. La copie de l'état vide de /balance/snapshot clarifie la différence entre un compte et un snapshot (#178).
|
- Bilan : remplacement de l'état vide de /balance par une carte d'onboarding à 2 étapes (Créer un compte → Saisir un snapshot) pour éviter l'écran « aucun snapshot » déroutant avant qu'un compte n'existe. Le bouton « + Nouveau snapshot » est masqué tant qu'aucun compte n'existe. La copie de l'état vide de /balance/snapshot clarifie la différence entre un compte et un snapshot (#178).
|
||||||
|
|
@ -21,8 +18,6 @@
|
||||||
- Bilan : la sauvegarde d'un snapshot utilise désormais une transaction atomique BEGIN/COMMIT et valide toutes les lignes avant toute écriture en BDD, empêchant les snapshots orphelins lorsque la validation échoue. La migration v11 nettoie les orphelins existants (#176).
|
- Bilan : la sauvegarde d'un snapshot utilise désormais une transaction atomique BEGIN/COMMIT et valide toutes les lignes avant toute écriture en BDD, empêchant les snapshots orphelins lorsque la validation échoue. La migration v11 nettoie les orphelins existants (#176).
|
||||||
- Bilan : le sélecteur de date sur `/balance/snapshot` se ferme maintenant après la sélection sur Linux (WebKitGTK) au lieu de rester ouvert jusqu'à ce que l'utilisateur appuie sur Échap. Le contournement appelle `blur()` sur le champ après chaque changement — sans effet sur Windows WebView2 / macOS WKWebView, où le sélecteur se ferme déjà automatiquement (#177).
|
- Bilan : le sélecteur de date sur `/balance/snapshot` se ferme maintenant après la sélection sur Linux (WebKitGTK) au lieu de rester ouvert jusqu'à ce que l'utilisateur appuie sur Échap. Le contournement appelle `blur()` sur le champ après chaque changement — sans effet sur Windows WebView2 / macOS WKWebView, où le sélecteur se ferme déjà automatiquement (#177).
|
||||||
- Mise à jour de la dépendance `postcss` (8.5.6 → 8.5.13) pour corriger l'avis de sécurité de sévérité modérée GHSA-qx2v-qp2m-jg93 (XSS via `</style>` non échappé dans le stringifier CSS). Transitive via vite, build-time uniquement — aucun impact runtime sur le binaire Tauri livré (#180).
|
- Mise à jour de la dépendance `postcss` (8.5.6 → 8.5.13) pour corriger l'avis de sécurité de sévérité modérée GHSA-qx2v-qp2m-jg93 (XSS via `</style>` non échappé dans le stringifier CSS). Transitive via vite, build-time uniquement — aucun impact runtime sur le binaire Tauri livré (#180).
|
||||||
- Contournement du sélecteur de date WebKitGTK étendu aux 7 autres champs `<input type="date">` natifs répartis sur 4 composants (barre de filtres Transactions, formulaire Ajustements, modal de Liaison de transferts, sélecteur de période). Chaque handler onChange appelle désormais `e.currentTarget.blur()` pour fermer le popup natif sur Linux Tauri WebView — sans effet sur Windows WebView2 / macOS WKWebView. Même approche que #177 (#188).
|
|
||||||
- Bilan : nettoyage post-merge des suggestions issues des reviews des PR #182-#185. Six corrections groupées : (1) `getStarterCollisions` filtre désormais `archived_at IS NULL`, donc recréer un compte starter volontairement archivé n'est plus bloqué ; (2) `proposeStarterAccounts` re-vérifie chaque collision (nom, catégorie) en transaction avant l'INSERT en défense-in-depth (saut silencieux si déjà présent, aucune contrainte UNIQUE ajoutée) ; (3) les nouveaux profils reçoivent désormais `balance_starter_proposed` pré-seedé dans `consolidated_schema.sql` pour que le StarterAccountsModal ne s'ouvre jamais brièvement avec uniquement des collisions à la première visite de /balance ; (4) `/balance` cache maintenant le sélecteur de période, le graphique d'évolution et le tableau des comptes tant que la carte d'onboarding vide est affichée (évite trois messages vides empilés) ; (5) `BalanceOnboardingCard.Step` appelle directement `useTranslation()` au lieu de recevoir `t` en prop ; (6) le bloc de doc de la formule Modified Dietz dans `return_calculator.rs` est maintenant entouré d'une fence `text` pour que `cargo test --doc` n'essaie plus de compiler la pseudo-math comme du Rust (#187).
|
|
||||||
|
|
||||||
## [0.9.0] - 2026-04-29
|
## [0.9.0] - 2026-04-29
|
||||||
|
|
||||||
|
|
@ -36,7 +31,7 @@
|
||||||
- **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 — é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)
|
- **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)
|
||||||
|
|
||||||
- **Récupération de prix premium pour actions (best-effort) et crypto (exchanges directs)** — vie privée préservée via proxy maximus-api. Toggle dans les Paramètres pour révoquer le consentement (activation serveur en attente — fonctionnalité dormante jusqu'à la mise en ligne de `/v1/prices` de maximus-api). (#160)
|
- **Récupération de prix premium pour actions (best-effort) et crypto (exchanges directs)** — vie privée préservée via proxy maximus-api. Toggle dans les Paramètres pour révoquer le consentement. (#160)
|
||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
- **Clé publique Ed25519 de licence** : la clé embarquée a été rotée pour correspondre au serveur de licences `maximus-api` qui vient d'être déployé en production (live à `https://api.lacompagniemaximus.com`). Aucune licence n'avait été émise en production avec l'ancienne clé, donc ce changement est invisible pour les utilisateurs existants — mais `/licenses/activate` répond désormais, donc l'activation par machine (issue #53) sera débloquée dès la sortie de cette version. La clé privée correspondante vit uniquement sur le serveur (#49)
|
- **Clé publique Ed25519 de licence** : la clé embarquée a été rotée pour correspondre au serveur de licences `maximus-api` qui vient d'être déployé en production (live à `https://api.lacompagniemaximus.com`). Aucune licence n'avait été émise en production avec l'ancienne clé, donc ce changement est invisible pour les utilisateurs existants — mais `/licenses/activate` répond désormais, donc l'activation par machine (issue #53) sera débloquée dès la sortie de cette version. La clé privée correspondante vit uniquement sur le serveur (#49)
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,12 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [0.9.1] - 2026-05-10
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Bilan: 4 starter accounts (Checking account, TFSA, RRSP, Non-registered account) are seeded for new profiles, and a one-shot opt-in modal proposes them to existing profiles on their first /balance visit. Default-checked checkboxes; existing accounts with the same name + category disable the matching row with a "Already exists" tooltip. Confirming or dismissing both write `user_preferences.balance_starter_proposed` so the modal never re-appears. ADR 0012 (Proposed) captures the future two-level vehicle × composition model (#179).
|
- Bilan: 4 starter accounts (Checking account, TFSA, RRSP, Non-registered account) are seeded for new profiles, and a one-shot opt-in modal proposes them to existing profiles on their first /balance visit. Default-checked checkboxes; existing accounts with the same name + category disable the matching row with a "Already exists" tooltip. Confirming or dismissing both write `user_preferences.balance_starter_proposed` so the modal never re-appears. ADR 0012 (Proposed) captures the future two-level vehicle × composition model (#179).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Price-fetching activated** — `maximus-api` `/v1/prices` is live in production since 2026-05-05. The premium price-fetching feature shipped in 0.9.0 (#160) is now functionally available end-to-end (#161).
|
|
||||||
- **Settings reorganized into 3 sub-pages** — the single 12-card `Settings` page is split into a hub (`/settings`) that links to three thematic sub-pages: `/settings/users` (accounts, licenses, user guide), `/settings/data` (categories, backup, price-fetch privacy) and `/settings/systems` (version, update, version history, logs + feedback). The user guide and changelog, previously full-page routes, are now embedded inside their parent sub-page; the legacy `/docs` and `/changelog` URLs redirect to keep external bookmarks and release-note links working. The token-store fallback security banner is now rendered once at the top of the settings layout, visible from every main settings sub-page (#190).
|
- **Settings reorganized into 3 sub-pages** — the single 12-card `Settings` page is split into a hub (`/settings`) that links to three thematic sub-pages: `/settings/users` (accounts, licenses, user guide), `/settings/data` (categories, backup, price-fetch privacy) and `/settings/systems` (version, update, version history, logs + feedback). The user guide and changelog, previously full-page routes, are now embedded inside their parent sub-page; the legacy `/docs` and `/changelog` URLs redirect to keep external bookmarks and release-note links working. The token-store fallback security banner is now rendered once at the top of the settings layout, visible from every main settings sub-page (#190).
|
||||||
- **App icon** — replaced the default Tauri scaffolding icon with a custom design: a robot-faced calculator with a privacy lock on the Enter / `=` key. Conveys the four product values — robot (assistant), simplicity (geometric shapes), accounting (calculator), privacy (lock). Source SVG kept at `src-tauri/icons/icon.svg` for future iterations; all 16 platform-specific raster sizes regenerated via `tauri icon`. Web favicon and window `<title>` updated too (was *"Tauri + React + Typescript"* from the default scaffold).
|
- **App icon** — replaced the default Tauri scaffolding icon with a custom design: a robot-faced calculator with a privacy lock on the Enter / `=` key. Conveys the four product values — robot (assistant), simplicity (geometric shapes), accounting (calculator), privacy (lock). Source SVG kept at `src-tauri/icons/icon.svg` for future iterations; all 16 platform-specific raster sizes regenerated via `tauri icon`. Web favicon and window `<title>` updated too (was *"Tauri + React + Typescript"* from the default scaffold).
|
||||||
- Bilan: replaced empty /balance state with a 2-step onboarding card (Create an account → Enter a snapshot) so users no longer see a confusing "no snapshot" screen before any account exists. The "+ New snapshot" button is hidden until at least one account exists. The /balance/snapshot empty-state copy now clarifies what an account is vs. what a snapshot is (#178).
|
- Bilan: replaced empty /balance state with a 2-step onboarding card (Create an account → Enter a snapshot) so users no longer see a confusing "no snapshot" screen before any account exists. The "+ New snapshot" button is hidden until at least one account exists. The /balance/snapshot empty-state copy now clarifies what an account is vs. what a snapshot is (#178).
|
||||||
|
|
@ -21,8 +18,6 @@
|
||||||
- Bilan: snapshot save now uses atomic BEGIN/COMMIT and validates all lines before any DB write, preventing orphan snapshot rows when validation fails. Migration v11 cleans existing orphans (#176).
|
- Bilan: snapshot save now uses atomic BEGIN/COMMIT and validates all lines before any DB write, preventing orphan snapshot rows when validation fails. Migration v11 cleans existing orphans (#176).
|
||||||
- Bilan: snapshot date picker on `/balance/snapshot` now closes after a date is selected on Linux (WebKitGTK), instead of staying open until the user pressed Esc. Workaround calls `blur()` on the input after each change — no-op on Windows WebView2 / macOS WKWebView, where the picker already auto-closes (#177).
|
- Bilan: snapshot date picker on `/balance/snapshot` now closes after a date is selected on Linux (WebKitGTK), instead of staying open until the user pressed Esc. Workaround calls `blur()` on the input after each change — no-op on Windows WebView2 / macOS WKWebView, where the picker already auto-closes (#177).
|
||||||
- Updated `postcss` dependency (8.5.6 → 8.5.13) to address moderate severity advisory GHSA-qx2v-qp2m-jg93 (XSS via unescaped `</style>` in CSS stringifier). Transitive via vite, build-time only — no runtime impact on the shipped Tauri binary (#180).
|
- Updated `postcss` dependency (8.5.6 → 8.5.13) to address moderate severity advisory GHSA-qx2v-qp2m-jg93 (XSS via unescaped `</style>` in CSS stringifier). Transitive via vite, build-time only — no runtime impact on the shipped Tauri binary (#180).
|
||||||
- WebKitGTK date picker workaround extended to the remaining 7 native `<input type="date">` fields across 4 components (Transactions filter bar, Adjustments form, Link Transfers modal, Period selector). Each onChange handler now calls `e.currentTarget.blur()` to dismiss the native popup on Linux Tauri WebView — no-op on Windows WebView2 / macOS WKWebView. Same approach as #177 (#188).
|
|
||||||
- Bilan: post-merge cleanup of suggestions raised in the #182-#185 reviews. Six fixes bundled: (1) `getStarterCollisions` now filters `archived_at IS NULL` so re-creating a voluntarily archived starter is no longer blocked; (2) `proposeStarterAccounts` re-checks each (name, category) collision in-transaction before INSERT as defense-in-depth (skips silently on hit, no UNIQUE constraint added); (3) brand-new profiles now get `balance_starter_proposed` pre-seeded in `consolidated_schema.sql` so the StarterAccountsModal never briefly opens with all-collisions on first /balance visit; (4) `/balance` now hides the period selector, evolution chart and accounts table while the empty-state onboarding card is shown (avoids three stacked empty messages); (5) `BalanceOnboardingCard.Step` now calls `useTranslation()` directly instead of receiving `t` as a prop; (6) `return_calculator.rs` Modified Dietz formula doc block is wrapped in a `text` fence so `cargo test --doc` no longer fails to compile pseudo-math as Rust (#187).
|
|
||||||
|
|
||||||
## [0.9.0] - 2026-04-29
|
## [0.9.0] - 2026-04-29
|
||||||
|
|
||||||
|
|
@ -36,7 +31,7 @@
|
||||||
- **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 — 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)
|
- **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)
|
||||||
|
|
||||||
- **Price-fetching premium for stocks (best-effort) and crypto (direct exchanges)** — privacy preserved via maximus-api proxy. Privacy toggle in Settings to revoke consent (server activation pending — feature dormant until maximus-api `/v1/prices` ships). (#160)
|
- **Price-fetching premium for stocks (best-effort) and crypto (direct exchanges)** — privacy preserved via maximus-api proxy. Privacy toggle in Settings to revoke consent. (#160)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **License Ed25519 public key** rotated to match the freshly deployed `maximus-api` license server (now live at `https://api.lacompagniemaximus.com`). No production licenses had been issued against the previous key, so this change is invisible to existing users — but `/licenses/activate` now answers, so machine activation (Issue #53) is unblocked once this release ships. The matching private key lives only on the server (#49)
|
- **License Ed25519 public key** rotated to match the freshly deployed `maximus-api` license server (now live at `https://api.lacompagniemaximus.com`). No production licenses had been issued against the previous key, so this change is invisible to existing users — but `/licenses/activate` now answers, so machine activation (Issue #53) is unblocked once this release ships. The matching private key lives only on the server (#49)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
# CLAUDE.md — Simpl'Résultat
|
# CLAUDE.md — Simpl'Résultat
|
||||||
|
|
||||||
@STATE.md
|
|
||||||
|
|
||||||
## Contexte du projet
|
## Contexte du projet
|
||||||
|
|
||||||
**Simpl'Résultat** est une application de bureau desktop **privacy-first** pour la gestion des finances personnelles. Elle traite localement les fichiers CSV bancaires sans aucune dépendance cloud. Projet solo entrepreneurial, en développement par Max.
|
**Simpl'Résultat** est une application de bureau desktop **privacy-first** pour la gestion des finances personnelles. Elle traite localement les fichiers CSV bancaires sans aucune dépendance cloud. Projet solo entrepreneurial, en développement par Max.
|
||||||
|
|
|
||||||
27
STATE.md
27
STATE.md
|
|
@ -1,27 +0,0 @@
|
||||||
# STATE — Simpl'Résultat
|
|
||||||
|
|
||||||
> Derniere MAJ : 2026-05-03 (par fix-issue #187 + #188)
|
|
||||||
|
|
||||||
## Position actuelle
|
|
||||||
|
|
||||||
Phase post-Bilan : milestone complet (5 sub-features merged, ADRs 0008-0010+0012). Polish + prep release pour shipper la nouvelle pubkey Ed25519 alignee sur maximus-api LIVE (`api.lacompagniemaximus.com`). Prochains gros chantiers : activation en ligne (#53), pipeline Stripe (#50, #135-136), price-fetching premium production (#161).
|
|
||||||
|
|
||||||
## Decisions recentes
|
|
||||||
|
|
||||||
- 2026-05-03 : Bilan post-merge cleanup (S1-S5+S7) — `getStarterCollisions` filtre `archived_at IS NULL`, in-txn re-check sur `proposeStarterAccounts`, pre-seed `balance_starter_proposed` pour nouveaux profils, guard empty-state `/balance`, `useTranslation` direct dans `Step`, doctest fence `text` sur Modified Dietz (ref #187)
|
|
||||||
- 2026-05-03 : WebKitGTK date picker workaround etendu aux 7 inputs date restants dans 4 composants (TransactionFilterBar, AdjustmentForm, LinkTransfersModal, PeriodSelector) (ref #188)
|
|
||||||
- 2026-05-03 : postcss 8.5.6 -> 8.5.13, fix GHSA-qx2v-qp2m-jg93 (transitif via vite, build-time only, exposition runtime nulle) (ref #180)
|
|
||||||
- 2026-05-02 : Settings eclate en 3 sous-pages `/settings/{users,data,systems}` + redirections legacy `/docs` et `/changelog` (ref #190)
|
|
||||||
- 2026-05-02 : Doc license — placeholder Bearer JWT-like remplace par `<license-token>` (ref #181)
|
|
||||||
- 2026-05-01 : WebKitGTK date picker — `blur()` apres selection sur `/balance/snapshot` (ref #177)
|
|
||||||
- 2026-05-01 : Icon Tauri custom (calculatrice + cadenas privacy), 16 raster sizes regenerees
|
|
||||||
- 2026-04-29 : Bilan starter accounts (4 comptes seedes + modal opt-in) + ADR 0012 vehicle x composition (ref #179)
|
|
||||||
- 2026-04-29 : Bilan onboarding 2-step empty state `/balance` (ref #178)
|
|
||||||
- 2026-04-28 : Bilan snapshot save atomic BEGIN/COMMIT + migration v11 cleanup orphans (ref #176)
|
|
||||||
|
|
||||||
## Blockers actifs
|
|
||||||
|
|
||||||
- #161 — feat(prices): production wiring + smoke test + release (BLOCKED par maximus-api Phase 2)
|
|
||||||
- #135 / #136 — maximus-api Stripe webhooks license auto-generate (BLOCKED par maximus-api Phase 2)
|
|
||||||
- #53 — online activation + machine limit enforcement (status:needs-fix)
|
|
||||||
- #50 / #52 — Stripe integration + purchase page (status:ready, design en attente)
|
|
||||||
|
|
@ -1,217 +0,0 @@
|
||||||
# ADR 0013 — Évaluation provider stocks : Alpha Vantage retenu comme cible de bascule (override partiel ADR 0011)
|
|
||||||
|
|
||||||
- Status: **Accepted**
|
|
||||||
- Date: 2026-05-07
|
|
||||||
- Successor of: ADR 0011 (override partiel — la pré-désignation **Tiingo Power** comme cible de bascule devient invalide ; **Alpha Vantage Premium** la remplace)
|
|
||||||
- Issue: [maximus-api#41](https://git.lacompagniemaximus.com/maximus/maximus-api/issues/41)
|
|
||||||
- Phase 1 research note: [maximus-api/docs/research/0013-stocks-providers-phase1.md](https://git.lacompagniemaximus.com/maximus/maximus-api/src/branch/issue-41-stocks-providers-research/docs/research/0013-stocks-providers-phase1.md)
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
L'ADR 0011 (2026-04-26) a adopté Yahoo Finance en best-effort pour les stocks, **avec un plan de bascule pré-désigné vers Tiingo Power (~10 $/mo, 1000 req/jour)** déclenché par triggers (1+ IP-ban/mois × 2 mois consécutifs, ou 30 %+ requêtes en service_degraded sur 7 jours, ou plainte légale).
|
|
||||||
|
|
||||||
Le smoke test 2026-05-04 (issue #25) a confirmé que Yahoo bloque l'IP du VPS OVH de manière stable. Un trigger ADR 0011 est de fait actif. Le feature stocks est non-fonctionnel en production.
|
|
||||||
|
|
||||||
**Décision encadrante (le présent ADR ne la modifie pas)** : on reste dans l'esprit MVP de l'ADR 0011 — **0 $ de cash burn tant que le produit n'a pas validé son marché**. La bascule vers un provider payant est repoussée jusqu'à un trigger plus net (1ère licence payée, OU 1ère plainte client active, OU saturation des plaintes "stocks cassé"). Le scope du présent ADR est de **valider empiriquement quel provider sera la cible de bascule** quand un trigger réel se déclenchera, **pas** de déclencher la bascule maintenant.
|
|
||||||
|
|
||||||
Avant de figer mécaniquement Tiingo Power comme dans 0011, l'évaluation a été élargie à 3 providers — Alpha Vantage, Tiingo, Polygon — pour potentiellement override la pré-désignation 0011 si un autre provider domine.
|
|
||||||
|
|
||||||
## Phase 1 — Recherche documentaire (sources publiques uniquement)
|
|
||||||
|
|
||||||
3 sous-agents WebSearch ont produit une synthèse 6-axes par provider. Synthèse complète dans `maximus-api/docs/research/0013-stocks-providers-phase1.md`.
|
|
||||||
|
|
||||||
Findings critiques :
|
|
||||||
|
|
||||||
| Critère | Alpha Vantage | Tiingo | Polygon |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **TSX coverage** | ✅ via `.TRT` / `.TRV` | ❓ non confirmé publiquement | ❌ non couvert |
|
|
||||||
| **Plan technique min pour 1500 rpm × 10k/jour** | aucun palier public ≥ 1500 rpm (top 1200 rpm @ ~$249.99/mo) | Power suffit techniquement (~$30/mo, 100k/jour) | Starter suffit techniquement ($29/mo "unlimited") |
|
|
||||||
| **ToS proxy mutualisé pour clients tiers payants** | ⚠️ zone grise, pas de clause explicite, email business pour cas commercial | ❌ Power = "internal consumption only" → Commercial $500+/mo + redistribution license | ❌ Individuals ToS interdit explicitement → Business négocié obligatoire |
|
|
||||||
| **HTTP error model** | ⚠️ 200 OK + champ `Note`/`Information` | ⚠️ 200 OK + body non-JSON sur quota | ✅ HTTP 429 standard |
|
|
||||||
| **Profondeur historique daily** | 20+ ans | 30+ ans US | 5 ans Starter |
|
|
||||||
| **Free tier viable pour dev** | ✅ 25 req/jour, 5 req/min — OK pour valider l'intégration | ⚠️ 1000 req/jour mais ToS interdit le commercial sans Commercial plan | ⚠️ 5 req/min, EOD only, ToS interdit le commercial sans Business |
|
|
||||||
|
|
||||||
**Finding majeur** : la pré-désignation ADR 0011 « Tiingo Power ~10 $/mo » est obsolète sur le prix (réel 2026 ≈ $30/mo) ET invalide sur le ToS (Power = internal-use only). Notre cas d'usage cible (proxy multi-tenant) forcerait Tiingo en plan Commercial ($500+/mo) avec redistribution license négociée. Polygon idem (Business plan, prix non public). Alpha Vantage seul reste en zone grise sans interdiction explicite et offre un free tier exploitable pour valider l'intégration en dev.
|
|
||||||
|
|
||||||
## Phase 2 — Smoke test live Alpha Vantage (2026-05-07, free tier)
|
|
||||||
|
|
||||||
13 calls réels sur `https://www.alphavantage.co/query` avec une clé free tier. Réponses brutes archivées dans `~/.maximus-research-keys/raw-av.json` (à supprimer après validation de cet ADR).
|
|
||||||
|
|
||||||
| # | Test | Résultat | Verdict |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 01 | `GLOBAL_QUOTE` AAPL | $287.44, day 2026-05-07 | Happy path ✅ |
|
|
||||||
| 02 | `TIME_SERIES_DAILY_ADJUSTED` AAPL | `Information` field — premium endpoint | ⚠️ **Adjusted close = premium $49.99/mo minimum** |
|
|
||||||
| 03 | `GLOBAL_QUOTE SHOP.TRT` | $152.57 CAD | TSX coverage confirmée ✅ |
|
|
||||||
| 04 | `GLOBAL_QUOTE RY.TRT` | $247.64 CAD | TSX big caps OK ✅ |
|
|
||||||
| 05 | **`GLOBAL_QUOTE SHOP.TO`** (Yahoo style) | **$152.57 — alias silencieux de `.TRT`** | 🎉 **Pas de mapping table requis** — drop-in Yahoo |
|
|
||||||
| 06 | `GLOBAL_QUOTE SHOP` (sans suffixe) | $111.74 — listing US différent | Suffixe nécessaire pour désambiguïsation CA vs US |
|
|
||||||
| 07 | `GLOBAL_QUOTE XYZAB123` (inconnu) | `"Global Quote": {}` (objet vide) | ⚠️ **Pas de `Error Message`** — détection = empty object |
|
|
||||||
| 08 | `GLOBAL_QUOTE SPX` | objet racine `{}` vide | ❌ Indices broad-market non couverts |
|
|
||||||
| 09 | `GLOBAL_QUOTE ^GSPC` | objet racine `{}` vide | ❌ Idem |
|
|
||||||
| 10 | `GLOBAL_QUOTE VTSAX` (mutual fund) | $176.23, day 2026-05-06 | Mutual funds OK ✅ |
|
|
||||||
| 11 | `GLOBAL_QUOTE BRK.B` (share class) | $475.08 | Format dot natif ✅ |
|
|
||||||
| 12 | `SYMBOL_SEARCH keywords=shopify` | 5 résultats incluant `SHOP.TRT` Toronto | Convention `.TRT` confirmée par AV |
|
|
||||||
| 13 | `TIME_SERIES_DAILY_ADJUSTED` AAPL outputsize=full | `Information` field — premium endpoint | ⚠️ Premium-gate global sur l'historique ajusté |
|
|
||||||
|
|
||||||
**Headers HTTP** : `Retry-After` absent sur les 13 réponses. Toutes en HTTP 200 (confirme la doc — pas de 4xx propres). Content-Type `application/json` partout.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**Override partiel de l'ADR 0011 — uniquement le provider de bascule désigné** :
|
|
||||||
|
|
||||||
- L'ADR 0011 reste en vigueur sur tout le reste : Yahoo best-effort en prod, garde-fous (badge UX, circuit breaker, quota 200 req/jour/licence, saisie manuelle toujours active), triggers de migration inchangés.
|
|
||||||
- **Le provider de bascule désigné passe de Tiingo Power à Alpha Vantage Premium $49.99/mo (75 rpm)** quand un trigger ADR 0011 se déclenchera réellement.
|
|
||||||
- **Aucune bascule immédiate.** Yahoo best-effort reste en prod tant qu'aucun trigger réel (1ère licence payée, plainte client formelle, saturation des incidents) ne justifie le cash burn.
|
|
||||||
|
|
||||||
### Pourquoi Alpha Vantage plutôt que Tiingo (pré-désignation 0011)
|
|
||||||
|
|
||||||
1. **Tiingo Power est invalide pour notre cas** : "internal consumption only" dans le ToS. Notre proxy multi-tenant force Tiingo en plan Commercial ~$500/mo + redistribution license — ~10× plus cher que la pré-désignation 0011 ($10/mo). Le rapport coût/bénéfice supposé par 0011 ne tient plus.
|
|
||||||
2. **Alpha Vantage Premium $49.99/mo** est le palier le moins cher qui (a) couvre le besoin technique avec marge (75 rpm × 1440 = ~108k req/jour, vs cible 10k/jour), (b) inclut `TIME_SERIES_DAILY_ADJUSTED` confirmé empiriquement comme premium-gate, (c) couvre le TSX nativement.
|
|
||||||
3. **Drop-in Yahoo via `.TO` natif** — découverte Phase 2 majeure : AV accepte le suffixe Yahoo `.TO` silencieusement comme alias de `.TRT`. **Aucune mapping table à coder.** Le code `yahooProvider.ts` existant peut être copié quasi-tel-quel en `alphaVantageProvider.ts`. C'est l'argument décisif pour la bascule rapide quand elle sera déclenchée — délai d'implémentation ~quelques heures, pas quelques jours.
|
|
||||||
4. **ToS en zone grise = négociable au moment voulu** : pas d'interdiction explicite (vs Polygon Individuals qui interdit, vs Tiingo Power qui interdit, vs Yahoo qui interdit). Email à `support@alphavantage.co` peut être envoyé au moment du déclenchement, pas avant.
|
|
||||||
|
|
||||||
### Polygon écarté
|
|
||||||
|
|
||||||
Polygon est techniquement supérieur (data quality, "unlimited" rpm sur Starter $29) mais **disqualifié seul par l'absence de couverture TSX**. Une stratégie hybride Polygon US + AV CA serait plus complexe et plus chère pour un bénéfice marginal vs AV seul.
|
|
||||||
|
|
||||||
### État du free tier AV (statu quo dev)
|
|
||||||
|
|
||||||
La clé free tier obtenue pendant l'évaluation reste active pour :
|
|
||||||
- **Dev / smoke test continu** : valider l'intégration en local avant tout déploiement payant.
|
|
||||||
- **Smoke test périodique de la cible de bascule** : 1× par mois, 5-10 calls pour vérifier que la convention `.TO` fonctionne toujours, que les premium-gates n'ont pas changé, que `support@alphavantage.co` n'a pas resserré le free tier.
|
|
||||||
|
|
||||||
La clé reste hors-Coolify (jamais déployée en prod), dans `~/.maximus-research-keys/av.txt` côté machine de dev. **À ne pas confondre avec un déploiement de prod** — le free tier 25 req/jour est 400× insuffisant pour servir 50 licences réelles.
|
|
||||||
|
|
||||||
## Plan de bascule (déclenché par trigger ADR 0011, pas maintenant)
|
|
||||||
|
|
||||||
Quand un trigger réel se déclenchera :
|
|
||||||
|
|
||||||
1. **Décision business** : confirmer que la bascule est justifiée vs amender ADR 0011 (rester sur Yahoo + mitigations alternatives).
|
|
||||||
2. **Email ToS** à `support@alphavantage.co` (draft inclus en annexe ci-dessous) — à envoyer **à ce moment-là**, pas maintenant. Délai de réponse standard 3-5 j ouvrables. Use case précisé : "server-side proxy serving N paying B2B licensees".
|
|
||||||
3. **Sur réponse positive** : signup Premium $49.99/mo (75 rpm). Clé en var Coolify `ALPHAVANTAGE_API_KEY` (secret). Issue follow-up `feat(api): integrer Alpha Vantage comme provider stocks` créée à ce moment-là.
|
|
||||||
4. **Sur réponse négative ou zone grise prolongée** : amender cet ADR pour retomber sur Tiingo Commercial ($500+/mo) — financièrement justifiable seulement si l'audience est suffisamment monétisée pour absorber le coût.
|
|
||||||
5. **Implémentation variante A** (remplacement direct, switch via env var `STOCKS_PROVIDER=alphavantage`). Yahoo retiré du code dans une seconde PR séquencée pour rollback rapide.
|
|
||||||
6. **Smoke test prod** : `?symbol=AAPL` et `?symbol=SHOP.TO` doivent renvoyer 200 + prix non-stale.
|
|
||||||
7. **Bascule + monitoring 7 jours**.
|
|
||||||
8. **Cleanup** : `~/.maximus-research-keys/` supprimé.
|
|
||||||
|
|
||||||
## Garde-fous obligatoires (mirror ADR 0011, applicables à AV au moment de la bascule)
|
|
||||||
|
|
||||||
1. **Label UX inchangé** : badge "best-effort" reste sur les catégories stocks. AV est plus stable que Yahoo mais reste un provider tiers — pas de SLA contractuel pour notre cas d'usage à $49.99/mo.
|
|
||||||
2. **Circuit breaker côté maximus-api** : seuil identique 5 erreurs AV / 60 sec → breaker ouvert 15 min. Notification Telegram/email à `maxime2tremblay@protonmail.com`.
|
|
||||||
3. **Quota côté maximus-api** : 200 req/jour/licence — inchangé. Avec 75 rpm × 1440 min = 108 000 req/jour de capacité, marge ample pour 50 licences.
|
|
||||||
4. **Saisie manuelle toujours active** : aucun chemin d'erreur ne bloque la saisie d'un snapshot.
|
|
||||||
5. **Headers stripping rigoureux** : tous les headers entrants supprimés. Vers AV : UA `maximus-api/<version>` (pas besoin de UA browser-like contrairement à Yahoo). Auth via query string `?apikey=` (limitation AV — pas de header support).
|
|
||||||
6. **Logs** : URL avec `?apikey=` masquée dans les logs Coolify/Traefik via une regex de filtre côté pino logger.
|
|
||||||
|
|
||||||
## Parsing défensif (issu des findings Phase 2 — applicable au moment de l'implémentation)
|
|
||||||
|
|
||||||
Le module `alphaVantageProvider.ts` doit gérer **5 cas distincts** sur HTTP 200 :
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 1. Happy path — body['Global Quote'] populated
|
|
||||||
if (body['Global Quote'] && Object.keys(body['Global Quote']).length > 0) { /* ok */ }
|
|
||||||
// 2. Symbol unknown — body['Global Quote'] = {} empty object
|
|
||||||
else if (body['Global Quote'] && Object.keys(body['Global Quote']).length === 0) { /* symbol_not_found */ }
|
|
||||||
// 3. Premium endpoint blocked — body['Information'] field with subscribe message
|
|
||||||
else if (body['Information']) { /* premium_required or rate_limit */ }
|
|
||||||
// 4. Error — body['Error Message'] field (param malformed)
|
|
||||||
else if (body['Error Message']) { /* invalid_request */ }
|
|
||||||
// 5. Rate limit hit — body['Note'] field (legacy free tier message)
|
|
||||||
else if (body['Note']) { /* rate_limit */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
Pas de fallback sur HTTP status (toujours 200). Le code de parsing yahoo existant ne couvre pas ces cas — adaptation requise dans la PR follow-up déclenchée par la bascule.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
### Positives
|
|
||||||
|
|
||||||
- **0 $ de cash burn maintenu** — l'esprit MVP de l'ADR 0011 est préservé. Pas de bascule prématurée à un provider payant.
|
|
||||||
- **Cible de bascule validée empiriquement** — `.TO` natif, TSX confirmé, mutual funds OK, format API simple. Au moment du trigger, la bascule prendra des heures, pas des jours.
|
|
||||||
- **Override propre de la pré-désignation Tiingo** — la décision 0011 ne s'auto-déclenche pas mécaniquement vers un provider mal calibré.
|
|
||||||
- **Email ToS reporté** — pas d'effort gaspillé tant qu'il n'y a pas d'enjeu réel.
|
|
||||||
|
|
||||||
### Négatives / risques actés
|
|
||||||
|
|
||||||
- **Yahoo reste cassé en prod** — feature stocks non-fonctionnel jusqu'au déclenchement du trigger ou résolution Yahoo (improbable). Acceptable tant qu'aucun client payant ne se plaint.
|
|
||||||
- **Adjusted close = premium endpoint** chez AV : confirmé empiriquement. Au moment de la bascule, le tier Premium $49.99 sera nécessaire (pas de chemin gratuit pour `TIME_SERIES_DAILY_ADJUSTED`).
|
|
||||||
- **Indices broad-market non couverts via `GLOBAL_QUOTE`** : SPX, ^GSPC, GSPTSE retournent objet vide. Hors-scope tant que la roadmap ne demande pas d'indices ; sinon ETF proxy (`SPY`, `XIC.TO`).
|
|
||||||
- **HTTP 200 sur toutes les erreurs** : parsing fragile, code défensif obligatoire (5 cas distincts à gérer).
|
|
||||||
- **Pas de `Retry-After` natif** : exponential backoff côté client requis sur détection de `Note`/`Information`.
|
|
||||||
- **Auth `?apikey=` query string uniquement** : leak risk dans les logs Coolify/Traefik. Mitigation = regex de masking pino.
|
|
||||||
- **ToS en zone grise jusqu'à confirmation écrite future** : risque de suspension de clé sans préavis si AV détecte un pattern multi-tenant. Probabilité faible à petite échelle, à monitorer.
|
|
||||||
- **Profondeur smallcaps TSXV non validée** : risque sur ~5-10 % des positions clients (à mitiger en Phase 4 par smoke test sur échantillon représentatif des holdings réels).
|
|
||||||
- **Free tier érodé historiquement** (500 → 100 → 25 req/jour) : signal qu'AV peut resserrer aussi les paid tiers à terme. Risque budget sur 12-24 mois.
|
|
||||||
|
|
||||||
### Neutre
|
|
||||||
|
|
||||||
- Le code reste `src/services/providers/<provider>Provider.ts` parallèle, switch via env var. Pattern déjà en place pour Yahoo, extension triviale.
|
|
||||||
|
|
||||||
## Triggers de bascule (rappel ADR 0011, inchangés)
|
|
||||||
|
|
||||||
- 1+ incidents IP-ban Yahoo / mois pendant 2 mois consécutifs, OU
|
|
||||||
- 30 %+ requêtes stocks tombent en `service_degraded` sur 7 jours, OU
|
|
||||||
- Plainte légale formelle de Yahoo / Verizon Media, OU
|
|
||||||
- (nouveau) 1ère licence payée active OU 1ère plainte client formelle sur le feature stocks.
|
|
||||||
|
|
||||||
Le 4ème trigger est ajouté pour aligner la décision avec la réalité business : tant qu'il n'y a pas de licence payée ou de plainte active, le 0 $ recurring l'emporte sur le service-quality.
|
|
||||||
|
|
||||||
## Suivi
|
|
||||||
|
|
||||||
- ADR à reviewer dans 6 mois (2026-11-07) ou plus tôt si :
|
|
||||||
- Trigger de bascule déclenché (réévaluer Yahoo vs AV en fonction du contexte du moment),
|
|
||||||
- AV resserre le free tier au point que le smoke test mensuel devient impossible,
|
|
||||||
- Yahoo redevient stable (improbable mais possible).
|
|
||||||
- Métriques à tracker dans le log applicatif maximus-api : `yahoo_success_rate_7d`, `yahoo_breaker_open_count_30d`, `crypto_provider_distribution`, `paid_licenses_active_count`.
|
|
||||||
- Smoke test mensuel AV free tier : entrée TODO dans le calendrier ou cron `claude` skill.
|
|
||||||
|
|
||||||
## Annexe — Draft email à `support@alphavantage.co` (à envoyer au moment de la bascule, pas maintenant)
|
|
||||||
|
|
||||||
```
|
|
||||||
Subject: Commercial use authorization — server-side proxy for ~N paying B2B licensees
|
|
||||||
|
|
||||||
Hello Alpha Vantage support team,
|
|
||||||
|
|
||||||
I am evaluating Alpha Vantage Premium for a small B2B SaaS use case and would like
|
|
||||||
to confirm the licensing model in writing before subscribing.
|
|
||||||
|
|
||||||
Use case:
|
|
||||||
- Server-side proxy (single VPS, single API key) on behalf of ~N paying B2B licensees
|
|
||||||
- Delayed/EOD US equities (NYSE/NASDAQ/AMEX) and Canadian equities (TSX via .TRT/.TO)
|
|
||||||
- Mutual funds (limited)
|
|
||||||
- ~Y 000 requests/day total, ~Z req/min peak
|
|
||||||
- No client-side redistribution: only the licensee's own portfolio holdings are
|
|
||||||
fetched, and the response data is consumed by the licensee's own desktop app
|
|
||||||
(no public-facing data feed, no resale to non-licensees)
|
|
||||||
|
|
||||||
Question:
|
|
||||||
1. Is this use case authorized under the standard Premium ToS, or do we need
|
|
||||||
a custom commercial agreement / data onboarding process?
|
|
||||||
2. Which tier would you recommend for the volume above?
|
|
||||||
3. Are there any per-end-user fees or exchange data fees I should be aware of
|
|
||||||
for delayed/EOD US + Canadian equities?
|
|
||||||
|
|
||||||
I would prefer to have written confirmation before subscribing, to comply with
|
|
||||||
my own internal documentation requirements (the ToS confirmation will be filed
|
|
||||||
as part of an internal architecture decision record).
|
|
||||||
|
|
||||||
Thanks for your help,
|
|
||||||
Maxime Tremblay
|
|
||||||
maxime2tremblay@protonmail.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Réponse écrite à archiver dans `simpl-resultat/docs/adr/0013-attachments/alphavantage-tos-confirmation-YYYY-MM-DD.txt` une fois reçue.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Alpha Vantage — API Documentation](https://www.alphavantage.co/documentation/)
|
|
||||||
- [Alpha Vantage — Premium API Key (pricing)](https://www.alphavantage.co/premium/)
|
|
||||||
- [Alpha Vantage — Terms of Service](https://www.alphavantage.co/terms_of_service/)
|
|
||||||
- [Alpha Vantage — Customer Support](https://www.alphavantage.co/support/)
|
|
||||||
- [Macroption — Alpha Vantage Symbols (suffixes)](https://www.macroption.com/alpha-vantage-symbols/)
|
|
||||||
- ADR 0009 — Architecture du proxy
|
|
||||||
- ADR 0011 — Providers best-effort Yahoo (override partiel sur le provider de bascule désigné)
|
|
||||||
- `maximus-api/docs/research/0013-stocks-providers-phase1.md` — Synthèse 3-way Phase 1
|
|
||||||
- `~/.maximus-research-keys/raw-av.json` — Réponses brutes Phase 2 smoke test (à supprimer après merge)
|
|
||||||
- Issue Forgejo `maximus-api#41`
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "simpl_result_scaffold",
|
"name": "simpl_result_scaffold",
|
||||||
"version": "0.9.1",
|
"version": "0.9.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "simpl_result_scaffold",
|
"name": "simpl_result_scaffold",
|
||||||
"version": "0.9.1",
|
"version": "0.9.0",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "simpl_result_scaffold",
|
"name": "simpl_result_scaffold",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.1",
|
"version": "0.9.0",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -4509,7 +4509,7 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simpl-result"
|
name = "simpl-result"
|
||||||
version = "0.9.1"
|
version = "0.9.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "simpl-result"
|
name = "simpl-result"
|
||||||
version = "0.9.1"
|
version = "0.9.0"
|
||||||
description = "Personal finance management app"
|
description = "Personal finance management app"
|
||||||
license = "GPL-3.0-only"
|
license = "GPL-3.0-only"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,7 @@
|
||||||
//!
|
//!
|
||||||
//! Modified Dietz formula:
|
//! Modified Dietz formula:
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! R = (V_end - V_start - sum(CF_i)) / (V_start + sum(W_i * CF_i))
|
||||||
//! R = (V_end - V_start - sum(CF_i)) / (V_start + sum(W_i * CF_i))
|
|
||||||
//! ```
|
|
||||||
//!
|
//!
|
||||||
//! where `W_i = (T - t_i) / T`, `T = period_days`, `t_i = days from period_start
|
//! where `W_i = (T - t_i) / T`, `T = period_days`, `t_i = days from period_start
|
||||||
//! to flow date`. A flow on day 0 is fully invested for the whole period
|
//! to flow date`. A flow on day 0 is fully invested for the whole period
|
||||||
|
|
|
||||||
|
|
@ -288,12 +288,6 @@ INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('theme', 'light');
|
||||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('currency', 'EUR');
|
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('currency', 'EUR');
|
||||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('date_format', 'DD/MM/YYYY');
|
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('date_format', 'DD/MM/YYYY');
|
||||||
INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v1');
|
INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v1');
|
||||||
-- Suppress StarterAccountsModal on first /balance visit for new profiles
|
|
||||||
-- (Issue #179). The 4 starter accounts are already seeded above, so the
|
|
||||||
-- modal would only show 4 collision rows with no actionable choice. Pre-
|
|
||||||
-- writing the pref skips that briefly-empty UX entirely. Suggestion S1
|
|
||||||
-- from PR #185 review (#187).
|
|
||||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('balance_starter_proposed', '{"shown_at":"seed","accepted":[]}');
|
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- Seed v1 — IPC Statistique Canada-aligned, 3 levels, Canada/Québec
|
-- Seed v1 — IPC Statistique Canada-aligned, 3 levels, Canada/Québec
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Simpl Resultat",
|
"productName": "Simpl Resultat",
|
||||||
"version": "0.9.1",
|
"version": "0.9.0",
|
||||||
"identifier": "com.simpl.resultat",
|
"identifier": "com.simpl.resultat",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|
|
||||||
|
|
@ -71,11 +71,7 @@ export default function AdjustmentForm({
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={form.date}
|
value={form.date}
|
||||||
onChange={(e) => {
|
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||||||
setForm({ ...form, date: e.target.value });
|
|
||||||
// Close native date popup on WebKitGTK (#177)
|
|
||||||
e.currentTarget.blur();
|
|
||||||
}}
|
|
||||||
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 border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
import { Wallet, FileText, Check, ArrowRight } from "lucide-react";
|
import { Wallet, FileText, Check, ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
interface BalanceOnboardingCardProps {
|
interface BalanceOnboardingCardProps {
|
||||||
|
|
@ -80,6 +81,7 @@ export default function BalanceOnboardingCard({
|
||||||
description={t("balance.onboarding.step1.description")}
|
description={t("balance.onboarding.step1.description")}
|
||||||
ctaLabel={t("balance.onboarding.step1.cta")}
|
ctaLabel={t("balance.onboarding.step1.cta")}
|
||||||
ctaHref="/balance/accounts"
|
ctaHref="/balance/accounts"
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
<Step
|
<Step
|
||||||
number={2}
|
number={2}
|
||||||
|
|
@ -90,6 +92,7 @@ export default function BalanceOnboardingCard({
|
||||||
ctaLabel={t("balance.onboarding.step2.cta")}
|
ctaLabel={t("balance.onboarding.step2.cta")}
|
||||||
ctaHref="/balance/snapshot"
|
ctaHref="/balance/snapshot"
|
||||||
disabledHint={t("balance.onboarding.step2.disabledHint")}
|
disabledHint={t("balance.onboarding.step2.disabledHint")}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -109,6 +112,7 @@ interface StepProps {
|
||||||
ctaLabel: string;
|
ctaLabel: string;
|
||||||
ctaHref: string;
|
ctaHref: string;
|
||||||
disabledHint?: string;
|
disabledHint?: string;
|
||||||
|
t: TFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Step({
|
function Step({
|
||||||
|
|
@ -120,8 +124,8 @@ function Step({
|
||||||
ctaLabel,
|
ctaLabel,
|
||||||
ctaHref,
|
ctaHref,
|
||||||
disabledHint,
|
disabledHint,
|
||||||
|
t,
|
||||||
}: StepProps) {
|
}: StepProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const isDone = state === "done";
|
const isDone = state === "done";
|
||||||
const isActive = state === "active";
|
const isActive = state === "active";
|
||||||
const isDisabled = state === "disabled";
|
const isDisabled = state === "disabled";
|
||||||
|
|
|
||||||
|
|
@ -229,11 +229,7 @@ export default function LinkTransfersModal({
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={from}
|
value={from}
|
||||||
onChange={(e) => {
|
onChange={(e) => setFrom(e.target.value)}
|
||||||
setFrom(e.target.value);
|
|
||||||
// Close native date popup on WebKitGTK (#177)
|
|
||||||
e.currentTarget.blur();
|
|
||||||
}}
|
|
||||||
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
|
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -244,11 +240,7 @@ export default function LinkTransfersModal({
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={to}
|
value={to}
|
||||||
onChange={(e) => {
|
onChange={(e) => setTo(e.target.value)}
|
||||||
setTo(e.target.value);
|
|
||||||
// Close native date popup on WebKitGTK (#177)
|
|
||||||
e.currentTarget.blur();
|
|
||||||
}}
|
|
||||||
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
|
className="w-full px-2 py-1.5 bg-[var(--background)] border border-[var(--border)] rounded text-sm"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -73,13 +73,6 @@ describe("getStarterCollisions", () => {
|
||||||
expect(result.has("tfsa")).toBe(false);
|
expect(result.has("tfsa")).toBe(false);
|
||||||
expect(result.has("cash")).toBe(false); // name "CELI" != "Compte chèque"
|
expect(result.has("cash")).toBe(false); // name "CELI" != "Compte chèque"
|
||||||
});
|
});
|
||||||
|
|
||||||
it("excludes archived accounts via SQL filter", async () => {
|
|
||||||
mockSelect.mockResolvedValueOnce([]);
|
|
||||||
await getStarterCollisions();
|
|
||||||
const sql = mockSelect.mock.calls[0][0];
|
|
||||||
expect(sql).toMatch(/archived_at IS NULL/);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("proposeStarterAccounts", () => {
|
describe("proposeStarterAccounts", () => {
|
||||||
|
|
@ -92,12 +85,10 @@ describe("proposeStarterAccounts", () => {
|
||||||
it("inserts selected starters atomically and returns their ids", async () => {
|
it("inserts selected starters atomically and returns their ids", async () => {
|
||||||
// BEGIN
|
// BEGIN
|
||||||
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 });
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 });
|
||||||
// For each starter: SELECT category id, SELECT in-txn collision check, INSERT
|
// For each starter: SELECT id FROM balance_categories + INSERT
|
||||||
mockSelect
|
mockSelect
|
||||||
.mockResolvedValueOnce([{ id: 11 }]) // cash category lookup
|
.mockResolvedValueOnce([{ id: 11 }]) // cash category
|
||||||
.mockResolvedValueOnce([{ count: 0 }]) // S3 collision check for cash
|
.mockResolvedValueOnce([{ id: 13 }]); // rrsp category
|
||||||
.mockResolvedValueOnce([{ id: 13 }]) // rrsp category lookup
|
|
||||||
.mockResolvedValueOnce([{ count: 0 }]); // S3 collision check for rrsp
|
|
||||||
mockExecute
|
mockExecute
|
||||||
.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 100 }) // INSERT cash
|
.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 100 }) // INSERT cash
|
||||||
.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 101 }) // INSERT rrsp
|
.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 101 }) // INSERT rrsp
|
||||||
|
|
@ -112,33 +103,9 @@ describe("proposeStarterAccounts", () => {
|
||||||
expect(sqls.filter((s) => /INSERT INTO balance_accounts/.test(s))).toHaveLength(2);
|
expect(sqls.filter((s) => /INSERT INTO balance_accounts/.test(s))).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips silently when in-txn collision check finds an existing account (S3)", async () => {
|
|
||||||
// BEGIN
|
|
||||||
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 });
|
|
||||||
// First starter "cash": category lookup succeeds, collision check returns count=1 → skip
|
|
||||||
mockSelect
|
|
||||||
.mockResolvedValueOnce([{ id: 11 }]) // cash category lookup
|
|
||||||
.mockResolvedValueOnce([{ count: 1 }]) // S3 collision: cash already exists
|
|
||||||
// Second starter "rrsp": category lookup + clean collision check
|
|
||||||
.mockResolvedValueOnce([{ id: 13 }]) // rrsp category lookup
|
|
||||||
.mockResolvedValueOnce([{ count: 0 }]); // rrsp clean
|
|
||||||
mockExecute
|
|
||||||
.mockResolvedValueOnce({ rowsAffected: 1, lastInsertId: 101 }) // INSERT rrsp
|
|
||||||
.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // COMMIT
|
|
||||||
|
|
||||||
const result = await proposeStarterAccounts(["cash", "rrsp"]);
|
|
||||||
expect(result).toEqual([101]); // only rrsp inserted, cash skipped silently
|
|
||||||
|
|
||||||
const sqls = mockExecute.mock.calls.map((c) => c[0]);
|
|
||||||
expect(sqls.filter((s) => /INSERT INTO balance_accounts/.test(s))).toHaveLength(1);
|
|
||||||
expect(sqls).toContain("COMMIT"); // no rollback — skip is normal flow
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rolls back on insert failure", async () => {
|
it("rolls back on insert failure", async () => {
|
||||||
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // BEGIN
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // BEGIN
|
||||||
mockSelect
|
mockSelect.mockResolvedValueOnce([{ id: 11 }]);
|
||||||
.mockResolvedValueOnce([{ id: 11 }]) // cash category
|
|
||||||
.mockResolvedValueOnce([{ count: 0 }]); // S3 collision check clean
|
|
||||||
mockExecute.mockRejectedValueOnce(new Error("disk full"));
|
mockExecute.mockRejectedValueOnce(new Error("disk full"));
|
||||||
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // ROLLBACK
|
mockExecute.mockResolvedValueOnce({ rowsAffected: 0, lastInsertId: 0 }); // ROLLBACK
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,11 +94,7 @@ export default function PeriodSelector({
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={localFrom}
|
value={localFrom}
|
||||||
onChange={(e) => {
|
onChange={(e) => setLocalFrom(e.target.value)}
|
||||||
setLocalFrom(e.target.value);
|
|
||||||
// Close native date popup on WebKitGTK (#177)
|
|
||||||
e.currentTarget.blur();
|
|
||||||
}}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
|
className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -109,11 +105,7 @@ export default function PeriodSelector({
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={localTo}
|
value={localTo}
|
||||||
onChange={(e) => {
|
onChange={(e) => setLocalTo(e.target.value)}
|
||||||
setLocalTo(e.target.value);
|
|
||||||
// Close native date popup on WebKitGTK (#177)
|
|
||||||
e.currentTarget.blur();
|
|
||||||
}}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
|
className="px-3 py-1.5 rounded-lg text-sm border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -167,11 +167,9 @@ export default function TransactionFilterBar({
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={filters.dateFrom ?? ""}
|
value={filters.dateFrom ?? ""}
|
||||||
onChange={(e) => {
|
onChange={(e) =>
|
||||||
onFilterChange("dateFrom", e.target.value || null);
|
onFilterChange("dateFrom", e.target.value || null)
|
||||||
// Close native date popup on WebKitGTK (#177)
|
}
|
||||||
e.currentTarget.blur();
|
|
||||||
}}
|
|
||||||
placeholder={t("transactions.filters.dateFrom")}
|
placeholder={t("transactions.filters.dateFrom")}
|
||||||
className="px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
className="px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||||
/>
|
/>
|
||||||
|
|
@ -179,11 +177,9 @@ export default function TransactionFilterBar({
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={filters.dateTo ?? ""}
|
value={filters.dateTo ?? ""}
|
||||||
onChange={(e) => {
|
onChange={(e) =>
|
||||||
onFilterChange("dateTo", e.target.value || null);
|
onFilterChange("dateTo", e.target.value || null)
|
||||||
// Close native date popup on WebKitGTK (#177)
|
}
|
||||||
e.currentTarget.blur();
|
|
||||||
}}
|
|
||||||
placeholder={t("transactions.filters.dateTo")}
|
placeholder={t("transactions.filters.dateTo")}
|
||||||
className="px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
className="px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -58,9 +58,10 @@ export default function BalancePage() {
|
||||||
|
|
||||||
// Issue #179 — one-shot starter-accounts modal for existing profiles. The
|
// Issue #179 — one-shot starter-accounts modal for existing profiles. The
|
||||||
// pref `balance_starter_proposed` is written once (confirmed or dismissed),
|
// pref `balance_starter_proposed` is written once (confirmed or dismissed),
|
||||||
// so the modal never re-appears. New profiles get both the 4 starters AND
|
// so the modal never re-appears. New profiles get the 4 starters seeded
|
||||||
// the pref pre-seeded via consolidated_schema.sql, so they never hit this
|
// directly via consolidated_schema.sql and never hit this branch (the
|
||||||
// branch at all (S1 fix from #187).
|
// first /balance visit will write the pref with accepted=[] silently
|
||||||
|
// since collisions match all 4).
|
||||||
const [showStarterModal, setShowStarterModal] = useState(false);
|
const [showStarterModal, setShowStarterModal] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -173,104 +174,96 @@ export default function BalancePage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Issue #178 — empty-state guard. We probe accountsLatest for ANY
|
<div className="space-y-6">
|
||||||
snapshot date so the guard is independent of the active period
|
{(() => {
|
||||||
filter (state.period). When empty, we render only the onboarding
|
// Issue #178 — show a 2-step onboarding card while the user has no
|
||||||
card — period selector, chart and accounts table would all show
|
// accounts or no snapshots yet. We probe accountsLatest for ANY
|
||||||
empty states stacked under it (S2 from #187). */}
|
// snapshot date so the empty-state guard is independent of the
|
||||||
{(() => {
|
// active period filter (state.period).
|
||||||
const accountsCount = state.accountsLatest.length;
|
const accountsCount = state.accountsLatest.length;
|
||||||
const hasAnySnapshot = state.accountsLatest.some(
|
const hasAnySnapshot = state.accountsLatest.some(
|
||||||
(a) => a.latest_snapshot_date != null
|
(a) => a.latest_snapshot_date != null
|
||||||
);
|
);
|
||||||
const isEmpty = accountsCount === 0 || !hasAnySnapshot;
|
if (accountsCount === 0 || !hasAnySnapshot) {
|
||||||
|
return (
|
||||||
if (isEmpty) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<BalanceOnboardingCard
|
<BalanceOnboardingCard
|
||||||
accountsCount={accountsCount}
|
accountsCount={accountsCount}
|
||||||
snapshotsCount={hasAnySnapshot ? 1 : 0}
|
snapshotsCount={hasAnySnapshot ? 1 : 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
}
|
return <BalanceOverviewCard totals={state.evolutionTotals} />;
|
||||||
|
})()}
|
||||||
|
|
||||||
return (
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<div className="space-y-6">
|
{/* Period selector */}
|
||||||
<BalanceOverviewCard totals={state.evolutionTotals} />
|
<div
|
||||||
|
role="group"
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
aria-label={t("balance.period.legend")}
|
||||||
{/* Period selector */}
|
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
|
||||||
<div
|
>
|
||||||
role="group"
|
{PERIOD_OPTIONS.map((p) => (
|
||||||
aria-label={t("balance.period.legend")}
|
<button
|
||||||
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
|
key={p}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPeriod(p)}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium ${
|
||||||
|
state.period === p
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
|
||||||
|
}`}
|
||||||
|
aria-pressed={state.period === p}
|
||||||
>
|
>
|
||||||
{PERIOD_OPTIONS.map((p) => (
|
{t(`balance.period.${p}`)}
|
||||||
<button
|
</button>
|
||||||
key={p}
|
))}
|
||||||
type="button"
|
|
||||||
onClick={() => setPeriod(p)}
|
|
||||||
className={`px-3 py-1.5 text-sm font-medium ${
|
|
||||||
state.period === p
|
|
||||||
? "bg-[var(--primary)] text-white"
|
|
||||||
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
|
|
||||||
}`}
|
|
||||||
aria-pressed={state.period === p}
|
|
||||||
>
|
|
||||||
{t(`balance.period.${p}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chart mode toggle */}
|
|
||||||
<div
|
|
||||||
role="group"
|
|
||||||
aria-label={t("balance.chart.modeLegend")}
|
|
||||||
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
|
|
||||||
>
|
|
||||||
{(["line", "stacked"] as BalanceChartMode[]).map((mode) => (
|
|
||||||
<button
|
|
||||||
key={mode}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setChartMode(mode)}
|
|
||||||
className={`px-3 py-1.5 text-sm font-medium ${
|
|
||||||
state.chartMode === mode
|
|
||||||
? "bg-[var(--primary)] text-white"
|
|
||||||
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
|
|
||||||
}`}
|
|
||||||
aria-pressed={state.chartMode === mode}
|
|
||||||
>
|
|
||||||
{t(`balance.chart.mode.${mode}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BalanceEvolutionChart
|
|
||||||
mode={state.chartMode}
|
|
||||||
totals={state.evolutionTotals}
|
|
||||||
byCategory={state.evolutionByCategory}
|
|
||||||
categoryLabels={categoryLabels}
|
|
||||||
transferMarkers={allTransferMarkers}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold mb-3">
|
|
||||||
{t("balance.overview.accountsTitle")}
|
|
||||||
</h2>
|
|
||||||
<BalanceAccountsTable
|
|
||||||
accounts={state.accountsLatest}
|
|
||||||
periodAnchor={state.accountsPeriodAnchor}
|
|
||||||
sinceCreationDate={earliestSnapshotDate}
|
|
||||||
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
|
|
||||||
onLinkTransfers={(acc) => setLinkTarget(acc)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})()}
|
{/* Chart mode toggle */}
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label={t("balance.chart.modeLegend")}
|
||||||
|
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
|
||||||
|
>
|
||||||
|
{(["line", "stacked"] as BalanceChartMode[]).map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setChartMode(mode)}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium ${
|
||||||
|
state.chartMode === mode
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
|
||||||
|
}`}
|
||||||
|
aria-pressed={state.chartMode === mode}
|
||||||
|
>
|
||||||
|
{t(`balance.chart.mode.${mode}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BalanceEvolutionChart
|
||||||
|
mode={state.chartMode}
|
||||||
|
totals={state.evolutionTotals}
|
||||||
|
byCategory={state.evolutionByCategory}
|
||||||
|
categoryLabels={categoryLabels}
|
||||||
|
transferMarkers={allTransferMarkers}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-3">
|
||||||
|
{t("balance.overview.accountsTitle")}
|
||||||
|
</h2>
|
||||||
|
<BalanceAccountsTable
|
||||||
|
accounts={state.accountsLatest}
|
||||||
|
periodAnchor={state.accountsPeriodAnchor}
|
||||||
|
sinceCreationDate={earliestSnapshotDate}
|
||||||
|
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
|
||||||
|
onLinkTransfers={(acc) => setLinkTarget(acc)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<StarterAccountsModal
|
<StarterAccountsModal
|
||||||
isOpen={showStarterModal}
|
isOpen={showStarterModal}
|
||||||
|
|
|
||||||
|
|
@ -487,8 +487,7 @@ export async function getStarterCollisions(): Promise<Set<string>> {
|
||||||
`SELECT c.key AS key, a.name AS account_name
|
`SELECT c.key AS key, a.name AS account_name
|
||||||
FROM balance_accounts a
|
FROM balance_accounts a
|
||||||
INNER JOIN balance_categories c ON c.id = a.balance_category_id
|
INNER JOIN balance_categories c ON c.id = a.balance_category_id
|
||||||
WHERE c.key IN ('cash','tfsa','rrsp','other')
|
WHERE c.key IN ('cash','tfsa','rrsp','other')`
|
||||||
AND a.archived_at IS NULL`
|
|
||||||
);
|
);
|
||||||
const collisions = new Set<string>();
|
const collisions = new Set<string>();
|
||||||
for (const starter of STARTER_ACCOUNTS) {
|
for (const starter of STARTER_ACCOUNTS) {
|
||||||
|
|
@ -509,11 +508,9 @@ export async function getStarterCollisions(): Promise<Set<string>> {
|
||||||
* in BEGIN/COMMIT — on any failure ROLLBACK is issued and the original error
|
* in BEGIN/COMMIT — on any failure ROLLBACK is issued and the original error
|
||||||
* is re-thrown. Returns the inserted account ids in input order.
|
* is re-thrown. Returns the inserted account ids in input order.
|
||||||
*
|
*
|
||||||
* Callers SHOULD pre-filter `selectedKeys` against `getStarterCollisions()`
|
* Callers MUST pre-filter `selectedKeys` against `getStarterCollisions()` so
|
||||||
* to keep the UI honest, but each iteration ALSO re-checks for an existing
|
* we never INSERT a duplicate (the table has no UNIQUE on (name, category),
|
||||||
* (name, category) account inside the transaction and skips silently on a
|
* so collisions would silently create dupes if not guarded upstream).
|
||||||
* hit — a defense-in-depth guard since the table has no UNIQUE constraint
|
|
||||||
* on (name, balance_category_id). Returned ids exclude any skipped starter.
|
|
||||||
*/
|
*/
|
||||||
export async function proposeStarterAccounts(
|
export async function proposeStarterAccounts(
|
||||||
selectedKeys: string[]
|
selectedKeys: string[]
|
||||||
|
|
@ -540,18 +537,6 @@ export async function proposeStarterAccounts(
|
||||||
`Seeded category '${starter.categoryKey}' missing — expected v9 schema`
|
`Seeded category '${starter.categoryKey}' missing — expected v9 schema`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Defense-in-depth: re-check collision in-txn before INSERT so we
|
|
||||||
// never create a silent duplicate even if the upstream pre-filter
|
|
||||||
// raced or was bypassed (S3 from PR #185 review).
|
|
||||||
const existing = await db.select<{ count: number }[]>(
|
|
||||||
`SELECT COUNT(*) AS count FROM balance_accounts
|
|
||||||
WHERE name = $1 AND balance_category_id = $2
|
|
||||||
AND archived_at IS NULL`,
|
|
||||||
[starter.name, catRows[0].id]
|
|
||||||
);
|
|
||||||
if ((existing[0]?.count ?? 0) > 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const result = await db.execute(
|
const result = await db.execute(
|
||||||
`INSERT INTO balance_accounts (balance_category_id, name, currency, is_active)
|
`INSERT INTO balance_accounts (balance_category_id, name, currency, is_active)
|
||||||
VALUES ($1, $2, 'CAD', 1)`,
|
VALUES ($1, $2, 'CAD', 1)`,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue