Compare commits
171 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e8e15719c | ||
|
|
af68d0623f | ||
|
|
34ca76d4bf | ||
|
|
f42f360dd0 | ||
|
|
e0c0f9ee45 | ||
|
|
ff9656d015 | ||
|
|
57ac056bf2 | ||
|
|
522232947c | ||
|
|
c667ad0079 | ||
|
|
c859405deb | ||
|
|
201a9779fe | ||
|
|
c5efc38e96 | ||
|
|
54c48f4721 | ||
|
|
24da72b7f4 | ||
|
|
fa1abb115c | ||
|
|
589aee0b38 | ||
|
|
a8c3fd6997 | ||
|
|
d5d0e89d2c | ||
|
|
cb7f8479e4 | ||
|
|
065f580c9f | ||
|
|
d8037be11f | ||
|
|
1763a9fdd3 | ||
|
|
8620049585 | ||
|
|
934bcbf9aa | ||
|
|
00be2877f3 | ||
|
|
a064043c20 | ||
|
|
eff77e47cc | ||
|
|
64a07f359f | ||
|
|
fade01d955 | ||
|
|
6b913709af | ||
|
|
63d8ba7589 | ||
|
|
e177a7561f | ||
|
|
00b4319c44 | ||
|
|
ed3a4bb9a0 | ||
|
|
2de4d9d255 | ||
|
|
7d3a616312 | ||
|
|
af884ca13b | ||
|
|
22dcad7a29 | ||
|
|
1c427a1b63 | ||
|
|
bf02b267b1 | ||
|
|
977ef3a1d3 | ||
|
|
c17bf5dcb4 | ||
|
|
86eff9b7a3 | ||
|
|
ac3a981f64 | ||
|
|
a1c31a1da7 | ||
|
|
74fae140b1 | ||
|
|
fbecfffa6e | ||
|
|
419b20e769 | ||
|
|
3882df772b | ||
|
|
2bd347b4a0 | ||
|
|
063f6c3d7d | ||
|
|
f5e5897df1 | ||
|
|
69ef92e65f | ||
|
|
79ba1ed2f3 | ||
|
|
1ba3b8bae9 | ||
|
|
86fd496e77 | ||
|
|
b161d54e3a | ||
|
|
8efe99aa20 | ||
|
|
08946e6d08 | ||
|
|
8ceb3de921 | ||
|
|
8898213e02 | ||
|
|
04ac2e933e | ||
|
|
4bdb00402e | ||
|
|
1954502d10 | ||
|
|
9e8c41b1f3 | ||
|
|
d1d025df8d | ||
|
|
08d54fd176 | ||
|
|
230ecd85ad | ||
|
|
ba3db7c35d | ||
|
|
e8306aa260 | ||
|
|
5ce136f45e | ||
|
|
da7129d529 | ||
|
|
3b8105c253 | ||
|
|
8d9a3ca9e9 | ||
|
|
f19b027c5d | ||
|
|
71a4e1e88f | ||
|
|
87920ec02d | ||
|
|
571104393b | ||
|
|
c7febe582e | ||
|
|
e4db715d6c | ||
|
|
6de73b3c22 | ||
|
|
ee17f31352 | ||
|
|
abc9076d23 | ||
|
|
ba71a6421d | ||
|
|
74a6572475 | ||
|
|
ec3b5d5185 | ||
|
|
696b83fb10 | ||
|
|
a58f8b98bc | ||
|
|
5c18fddfc0 | ||
|
|
d6f42dbe32 | ||
|
|
7ce5816bb2 | ||
|
|
b0db1d3102 | ||
|
|
d76c387fa5 | ||
|
|
2386486762 | ||
|
|
9a695a2504 | ||
|
|
fd44d8702b | ||
|
|
ee26fe0888 | ||
|
|
ed5eeb0265 | ||
|
|
d024f5aeb9 | ||
|
|
46e714234e | ||
|
|
2388ad62fa | ||
|
|
75c1708bbe | ||
|
|
21c46b6380 | ||
|
|
68b90f5f16 | ||
|
|
dcf3b013dc | ||
|
|
4febed9eab | ||
|
|
4a82116614 | ||
|
|
5910387eb7 | ||
|
|
71147c5083 | ||
|
|
02fca2ad3c | ||
|
|
b0206ae56b | ||
|
|
f758282c2e | ||
|
|
8907eef5df | ||
|
|
07255e1f30 | ||
|
|
8f964e096b | ||
|
|
2cbc886653 | ||
|
|
fd90de5424 | ||
|
|
ed35570121 | ||
|
|
e2f0fb86da | ||
|
|
0b94ccf006 | ||
|
|
ab78aa9ddc | ||
|
|
f25291eabb | ||
|
|
c56f09a49b | ||
|
|
8c55e2670b | ||
|
|
667e058a77 | ||
|
|
d11337ccf6 | ||
|
|
624eef7ba8 | ||
|
|
e0ee09bca7 | ||
|
|
e4a0f1042e | ||
|
|
8eac47f69b | ||
|
|
3f3531392e | ||
|
|
a060f0e0e0 | ||
|
|
76fd8d376a | ||
|
|
f77ce94505 | ||
|
|
7a7fdf90e2 | ||
|
|
f821492274 | ||
|
|
20db766f52 | ||
|
|
cf1da2bea0 | ||
|
|
0e4fef5ad7 | ||
|
|
1923f8f70b | ||
|
|
56fc612598 | ||
|
|
6625d252e1 | ||
|
|
b107cf42ce | ||
|
|
4225dadebe | ||
|
|
e24c30fe6a | ||
|
|
a9fa34dd54 | ||
|
|
0a9c9f71dd | ||
|
|
e5662ad018 | ||
|
|
c10173086e | ||
|
|
ce408bfb7d | ||
|
|
93a56b1f9a | ||
|
|
cdf4df85f7 | ||
|
|
3f90635298 | ||
|
|
d2ded515fd | ||
|
|
5d549d674f | ||
|
|
170b22d356 | ||
|
|
57a0fbc2f7 | ||
|
|
de274ec85e | ||
|
|
0c9b8f8f1a | ||
|
|
b8e67c3d4f | ||
|
|
a051b96c87 | ||
|
|
7d667f7ca3 | ||
|
|
7b852c9969 | ||
|
|
11e12ab7a3 | ||
|
|
14f1d3b226 | ||
|
|
9abf4743e5 | ||
|
|
8ea3410c82 | ||
|
|
97ee3c354b | ||
|
|
a9188b22a0 | ||
|
|
f210bf3647 | ||
|
|
825c602ca9 |
239 changed files with 37336 additions and 1764 deletions
|
|
@ -1,7 +1,16 @@
|
|||
---
|
||||
paths: ["**/migrations/**", "**/*.sql", "**/lib.rs"]
|
||||
paths: ["**/migrations/**", "**/*.sql", "**/lib.rs", "**/profile_db.rs"]
|
||||
---
|
||||
Ne JAMAIS modifier une migration SQL existante. Toujours creer une nouvelle migration.
|
||||
Les checksums sont verifies au demarrage (SHA-384 dans `_sqlx_migrations`).
|
||||
Les migrations sont inline dans `lib.rs` via `tauri_plugin_sql::Migration`.
|
||||
Les checksums sont verifies au demarrage (SHA-384 dans `_sqlx_migrations`) et epingles par le test `migration_checksums_match_the_released_ones`.
|
||||
Les migrations sont inline dans `lib.rs`, rendues par `migrations()` via `tauri_plugin_sql::Migration`.
|
||||
Le schema consolide (`consolidated_schema.sql`) sert uniquement pour les nouveaux profils.
|
||||
|
||||
Depuis #390 (ADR 0021), chaque nouvelle migration atteint deux chemins :
|
||||
- le profil par defaut, migre par tauri-plugin-sql (sqlx) ;
|
||||
- les profils secondaires, rattrapes a l'ouverture par `database::profile_db` (rusqlite), cles etrangeres actives comme sous sqlx.
|
||||
|
||||
Une migration doit donc rester correcte sur les deux, et :
|
||||
- tout changement de schema se reporte dans `consolidated_schema.sql` (le test des etats publies compare chaque profil rattrape a un profil neuf) ;
|
||||
- toute correction de donnees (seed, mots-cles, categories) se reporte AUSSI dans `consolidated_schema.sql` : un nouveau profil nait avec l'historique complet et ne rejoue jamais les migrations ;
|
||||
- ne pas ajouter de marqueur a `infer_floor` : il ne sert qu'aux bases creees avant #390.
|
||||
|
|
|
|||
78
.claude/skills/issue-license/SKILL.md
Normal file
78
.claude/skills/issue-license/SKILL.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
name: issue-license
|
||||
description: Émettre à la main une ou plusieurs licences Simpl-Résultat (SR-BASE / SR-PREMIUM) via l'endpoint admin de maximus-api, sans passer par Stripe, pour tout bénéficiaire : testeur, cadeau, compensation après un remboursement, compte admin, utilisateur hors Stripe ; et les distribuer avant une release qui verrouille des fonctionnalités par édition. Use when the user says "émets une licence", "clé pour Michel", "offre une licence à X", "donne une licence Premium à X", "il me faut une clé de test", "génère une licence", "compense le remboursement de X", "les testeurs doivent garder l'accès", "issue a license", "generate license key", "gift a license", or before tagging a release that changes FEATURE_TIERS.
|
||||
user-invocable: true
|
||||
allowed-tools: Bash, Read, Write
|
||||
created: 2026-09-13
|
||||
updated: 2026-09-24
|
||||
---
|
||||
|
||||
# /issue-license — Émettre des licences à la main (testeurs, cadeaux, compensations, admin)
|
||||
|
||||
Émission via `POST /v1/licenses/generate` de maximus-api (route admin, Bearer `ADMIN_API_KEY`), hors Stripe, pour tout bénéficiaire : testeur, cadeau, compensation après un remboursement, compte admin. La clé admin et les clés émises ne transitent jamais par la conversation.
|
||||
|
||||
## Faits vérifiés (2026-09-06, code v0.14.0 → main + maximus-api prod)
|
||||
|
||||
- Validation **locale** de la clé (Ed25519, clé publique embarquée + `exp`), aucun réseau, déverrouillage immédiat sans redémarrage. La clé publique est identique entre v0.14.0 et `main` → une clé peut être entrée **avant** la mise à jour.
|
||||
- Endpoint : `POST https://api.lacompagniemaximus.com/v1/licenses/generate`, Bearer `ADMIN_API_KEY` (celle de prod vit dans Coolify → maximus-api → Environment Variables ; le `.env` local en a une autre). Payload : `product: "simpl-resultat"`, `email` (affiché dans la carte licence → vrai courriel), `edition` (`base` | `premium`), `expires_in_days` (défaut et max 3650), `machine_limit` (défaut 3, max 10). Réponse `{ key: "SR-<EDITION>-<JWT>", expires_at }`. **N'envoie aucun courriel** (seul le flux Stripe le fait) → distribution manuelle. Rate limit 5/min sur `/v1/licenses/*`.
|
||||
- Activation machine optionnelle : l'édition résout sans `activation.token` (`license_commands.rs`, état « graceful pre-activation »). Seule exception : le fetch de cours du Bilan envoie `activation.token` en Bearer à `/v1/prices` (`balance_commands.rs`) → un testeur Premium qui utilise les cours doit cliquer « Activer cette machine ». Une clé non activée marche sur n'importe quel poste ; activée, elle est liée (3 machines par défaut).
|
||||
- Un compte Logto n'est **pas** requis : `generate` prend un courriel libre. Un compte Logto sans abonnement `active` ne donne rien côté app.
|
||||
- Clé révoquée (`is_revoked = t`) : elle continue de déverrouiller l'UI hors ligne, seul le serveur (activation, cours) la refuse. Une nouvelle clé saisie écrase l'ancienne dans `license.key` du client.
|
||||
- Bénéficiaires connus (testeurs) : Max (`maxime2tremblay@protonmail.com`) et Michel (Logto `michelt` ; son courriel est dans la table `users` de Logto et dans la mémoire projet `project-tester-licenses`, pas ici). Les deux en **Premium**, 3650 j, 3 machines (décision Max 2026-09-06).
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Inventaire des licences existantes** (commande à Max en `!`, le classifier bloque les `ssh` VPS en mode auto) :
|
||||
```
|
||||
! ssh ubuntu@100.86.30.21 "docker exec zgkgc8soo8k4k44woo00ckg8 psql -U postgres -d maximus_api -t -A -F ' | ' -c \"SELECT product, edition, user_email, machine_limit, is_revoked, created_at::date, expires_at::date FROM licenses ORDER BY created_at;\""
|
||||
```
|
||||
Repérer les clés révoquées (`is_revoked = t`) et les clés déjà émises pour le même courriel avant d'en créer une nouvelle.
|
||||
2. **Clé admin hors transcript** : Max la dépose depuis son propre terminal (jamais en `!`, qui écrirait la clé dans la transcription). Valeur : Coolify → maximus-api → Environment Variables → `ADMIN_API_KEY`.
|
||||
```
|
||||
printf '%s' 'LA_CLÉ' > ~/.maximus-admin-key && chmod 600 ~/.maximus-admin-key
|
||||
```
|
||||
Guillemets simples obligatoires (pas d'expansion, pas de retour à la ligne). Si l'historique shell est actif : `history -d $(history 1 | awk '{print $1}')`.
|
||||
3. **Confirmer les paramètres** avec Max : courriel du bénéficiaire (affiché dans la carte licence), `edition` (`base` | `premium`), `machine_limit` (défaut 3, max 10), `expires_in_days` (défaut et max 3650). Puis **émettre** avec le script (lit `~/.maximus-admin-key`, écrit les clés dans `~/simpl-resultat-licences-<date>.txt` en 600, n'affiche que courriel / édition / expiration) :
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
KEY=$(cat ~/.maximus-admin-key); OUT=~/simpl-resultat-licences-$(date +%F).txt; umask 077; : > "$OUT"
|
||||
while IFS='|' read -r email edition; do
|
||||
resp=$(curl -sS -X POST https://api.lacompagniemaximus.com/v1/licenses/generate \
|
||||
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
|
||||
-d "{\"product\":\"simpl-resultat\",\"email\":\"$email\",\"edition\":\"$edition\",\"expires_in_days\":3650,\"machine_limit\":3}")
|
||||
printf '%s | %s | %s\n' "$email" "$edition" "$(echo "$resp" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("expires_at"))')"
|
||||
printf '%s\t%s\t%s\n' "$email" "$edition" "$(echo "$resp" | python3 -c 'import json,sys;print(json.load(sys.stdin)["key"])')" >> "$OUT"
|
||||
sleep 13 # rate limit 5/min
|
||||
done <<'EOF'
|
||||
<courriel>|premium
|
||||
EOF
|
||||
echo "Clés dans $OUT"
|
||||
```
|
||||
Une ligne `courriel|edition` par bénéficiaire (une seule pour un cadeau ou une compensation). Le script vit dans le repo (`scripts/issue-license.sh`, jamais la liste des clés) ou dans le scratchpad — pas de clé dans git ni dans la conversation. Ne jamais `cat` le fichier de sortie dans la session.
|
||||
4. **Vérifier la clé sans l'afficher**, contre le validateur du client (`src-tauri/src/commands/license_commands.rs` : `PUBLIC_KEY_PEM` embarquée, identique entre les versions publiées, et `strict_validation()`) : lire la clé depuis le fichier dans un script (jamais en argument de commande), vérifier le préfixe `SR-<EDITION>-`, la signature Ed25519 du JWT, la présence de `exp` et `iat`, `edition` et `machine_limit` attendus. N'imprimer que le verdict.
|
||||
5. **Livrer** : le fichier va sur le poste de Max via `transfer-asset` (Pop!_OS, `~/Documents/Projets/simpl-resultat/`), avec `scp -p` pour garder le 600 et `sha256sum` des deux côtés ; annoncer chemin, taille, permissions et empreinte tronquée, jamais le contenu. Fournir la marche à suivre (FR/EN) au bénéficiaire : Paramètres → Utilisateurs → carte Licence → coller → « Activer ». Premium avec cours : cliquer aussi « Activer cette machine ».
|
||||
6. **Tracer sans la clé** : mémoire projet (qui, quand, édition, où est le fichier), ligne dans `STATE.md`. Proposer `rm ~/.maximus-admin-key` une fois terminé (la clé reste dans Coolify).
|
||||
7. **Si le déclencheur est une release** : `/release` seulement après. Ordre inverse = bénéficiaires coincés en Free sans auto-update.
|
||||
|
||||
## Règles
|
||||
|
||||
- La clé admin et les clés émises ne vont **jamais** dans la conversation, une mémoire, un repo ou une issue.
|
||||
- Le courriel d'un bénéficiaire tiers ne va pas dans ce fichier ni ailleurs dans le repo : le dépôt `Simpl-Resultat` est **public**. Le désigner par son identifiant Logto ; le courriel se relit au moment d'émettre.
|
||||
- Émettre **avant** de tagger une release qui resserre le gating : l'auto-update est Base+ depuis v0.15.0, donc une installation sans clé reçoit cette version, puis plus rien.
|
||||
- L'activation machine (`activation.token`) n'est requise que pour les endpoints serveur (cours du Bilan) ; la clé seule débloque tout le reste, hors ligne.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Le classifieur du mode auto bloque tout `ssh` vers le VPS (Tailscale comme IP publique) : remettre la commande à Max en `!` avec le texte exact plutôt que la reformuler ou contourner.
|
||||
- `Connection timed out` sans lien de reauth Tailscale = clé de nœud expirée (180 j), pas une attente navigateur — voir le skill global `transfer-asset`.
|
||||
- `.rpm` : aucune mise à jour quelle que soit la licence (#320).
|
||||
- Un testeur multi-profils qui monte en v0.15.0 sans clé reste coincé sur le profil actif au moment de l'upgrade (données intactes, ADR 0017 §5). Pas de clause de grand-père ni de période de grâce dans le code ; `SR_DEV_EDITION` est compilé hors des builds publiés.
|
||||
- Le binaire v0.14.0 appelle encore `/licenses/activate` (#270 non livré) : le serveur répond 308 → `/v1/licenses/activate` et infère le produit du préfixe `SR-` (#48) — ça passe.
|
||||
- Tenir la liste des bénéficiaires ici, par identifiant Logto, pas dans Forgejo (aucune org, 3 comptes seulement) : la source des courriels est la table `users` de Logto (tenant `default`).
|
||||
|
||||
## Changelog
|
||||
|
||||
- 2026-09-13 — Creation initiale via /skills-review-inbox (draft 2026-09-06, source 690a6129). Procedure d'emission de licences testeurs reconstituee avant la release v0.15.0 (verrouillage par edition) ; faits verifies sur le code v0.14.0 -> main et maximus-api prod au 2026-09-06.
|
||||
- 2026-09-13 — Fusion de la proposition `issue-tester-licence` (draft 2026-09-12, même source 690a6129) et élargissement du périmètre à tout bénéficiaire hors Stripe (cadeaux, compensation après remboursement, compte admin), décision Max. Ajouts : sémantique des clés révoquées, dépôt de la clé admin hors historique shell, étape de vérification locale de la clé sans l'afficher, livraison `scp -p` + `sha256sum`, traçabilité sans la clé et retrait de la clé admin, section Règles, gotchas classifieur `ssh` VPS et clé de nœud Tailscale expirée. Heredoc du script passé en placeholder.
|
||||
- 2026-09-24 — Courriel personnel de Michel retiré avant le premier commit : le dépôt est public et l'adresse n'était dans aucun fichier suivi. Il est désigné par son identifiant Logto `michelt`, et une règle interdit le courriel d'un tiers dans le repo. Le courriel de Max est conservé : il figure déjà dans trois fichiers suivis (ADR 0011, ADR 0013, `docs/api-contract-prices.md`). Source : session 997f21af (/warmup).
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
name: release
|
||||
description: Release a new version of Simpl-Resultat (bump, changelog, tag, push)
|
||||
user-invocable: true
|
||||
updated: 2026-07-27
|
||||
updated: 2026-10-11
|
||||
---
|
||||
|
||||
# /release — Release Simpl-Resultat
|
||||
|
|
@ -16,6 +16,8 @@ updated: 2026-07-27
|
|||
## Workflow
|
||||
|
||||
0. **Pré-vol — revalider le tip localement.** Les workflows `check-rust.yml` / `check-frontend.yml` ne tournent pas sur `main` : le tip mergé n'a jamais été vu par le CI (le dernier run vert portait sur la branche d'issue avant merge), et le tag grave ce tip exact dans des binaires distribués. Lancer `npm run build && npm test` (vitest) + `cd src-tauri && cargo check && cargo test`. Vérifier aussi que `.claude/worktrees/` est vide (worktrees leftover → vitest récurse et gonfle le compteur). Ne tagger que sur un tip vert.
|
||||
**Accès des testeurs.** Si `[Unreleased]` touche `FEATURE_TIERS`, le gating par édition ou `auto-update` : vérifier que chaque testeur (Max, Michel) a une clé Base+ **saisie avant le tag**, émise via `/issue-license` au besoin — la clé s'entre dans la version installée et survit à la mise à jour ; une installation sans clé reçoit cette version, puis plus rien. Cas réel v0.15.0 : deux clés Premium émises le jour même, juste avant le tag.
|
||||
**Tableau des éditions du site.** Si `[Unreleased]` change la matrice des éditions (`ENTITLEMENTS` de `src/shared/entitlements.ts`, `FEATURE_TIERS` de `src-tauri/src/commands/entitlements.rs`, ou un module Free qui passe sous verrou, comme le fractionnement de #369) : la page produit du site en a une copie (`la-compagnie-maximus/src/data/editions.ts`, tableau comparatif et phrase `simplresultat_detail_desc`). Préparer la PR du site avant le tag en suivant la section « Matrice des éditions — Simpl-Résultat » de son `CLAUDE.md`, et ne la déployer qu'**après** la publication de la release : avant, le tableau promettrait un verrou absent des binaires.
|
||||
1. Determiner la nouvelle version (argument utilisateur ou demander)
|
||||
2. Bump version dans les 5 fichiers :
|
||||
- `src-tauri/Cargo.toml` (ligne `version = "..."`)
|
||||
|
|
@ -24,6 +26,12 @@ updated: 2026-07-27
|
|||
- `package.json` (champ `"version"`)
|
||||
- `package-lock.json` (deux champs `"version"` — root ~ligne 3 et le package racine `""` ~ligne 9)
|
||||
- Si `package-lock.json` est stale (hygiene warning `package-lock.json plus ancien que package.json`) : `npm install --package-lock-only --no-audit --no-fund` pour resync. Note : peut ajouter des entrees bundled optionnelles (tailwindcss oxide wasm etc.) — cosmetique, pas d'install effective.
|
||||
- Puis rejouer le garde `windows-sys` de `check-rust.yml` sur le lock édité : le commit de release touche `Cargo.lock` directement sur `main`, sans PR, et `check-rust` ne le verrait qu'au lundi suivant, après le build du tag ([ADR 0023](../../../docs/adr/0023-windows-sys-pinning.md)). Depuis la racine du dépôt, le texte exact de l'étape avec les options du runner :
|
||||
```bash
|
||||
python3 -c 'import sys, yaml; steps = yaml.safe_load(open(".forgejo/workflows/check-rust.yml"))["jobs"]["rust"]["steps"]; sys.stdout.write(next(s["run"] for s in steps if s.get("name") == "Verify windows-sys pinning"))' \
|
||||
| bash --noprofile --norc -e -o pipefail
|
||||
```
|
||||
Attendu : code de sortie 0 et dernière ligne `windows-sys pinning: N checks, exit 0`. Une ligne `FAIL:` arrête la release : suivre la procédure de l'ADR 0023 avant de tagger.
|
||||
3. Mettre a jour les 2 changelogs — format **Keep a Changelog** :
|
||||
- `CHANGELOG.md` (EN) : header `## [Unreleased]`
|
||||
- `CHANGELOG.fr.md` (FR) : header `## [Non publié]` (PAS `[Unreleased]` — ne pas le confondre avec un changelog vide)
|
||||
|
|
@ -39,9 +47,24 @@ updated: 2026-07-27
|
|||
7. Push : `git push origin main && git push origin vX.Y.Z`
|
||||
8. Forgejo CI build automatique (Windows + Linux) via `release.yml` sur `on: push: tags: v*`
|
||||
9. **Post-CI — vérifier la release publiée.** Surveiller `release.yml` (outil `Monitor` sur le run), puis vérifier la release réellement attachée — `status=success` du workflow ne suffit pas :
|
||||
- Les **7 artefacts** attendus : `.exe` NSIS, `.deb`, `.rpm`, leurs 3 signatures `.sig`, et `latest.json`.
|
||||
- Le contenu de `latest.json` (il pilote l'auto-update des installations existantes) : champ `version` correct, signatures non vides pour les deux plateformes, URLs pointant vers les bons binaires, notes extraites du CHANGELOG.
|
||||
10. **Post-CI — dérouler un vrai cycle de mise à jour** si la release touche `tauri-plugin-updater`, ses dépendances (`reqwest`/`rustls-webpki`, `tar`), `release.yml`, ou la config `updater` de `tauri.conf.json` : [`docs/qa-update-cycle.md`](../../../docs/qa-update-cycle.md).
|
||||
```bash
|
||||
bash .claude/skills/release/scripts/verify-release.sh vX.Y.Z
|
||||
```
|
||||
Le script (token `~/.forgejo-token`) lit la release par tag, compte les 7 artefacts **par suffixe** (`.exe` NSIS, `.deb`, `.rpm`, leurs 3 signatures `.sig`, `latest.json` — les noms exacts varient selon la version de Tauri), puis télécharge `latest.json` (il pilote l'auto-update des installations existantes) et vérifie `version` (il affiche `pub_date` et la longueur des notes), puis exige **exactement trois clés** (#320) : `linux-x86_64-deb`, `linux-x86_64-rpm` et `windows-x86_64`, sans clé générique `linux-x86_64` depuis le 2026-10-11 (`docs/qa-update-cycle.md`). Pour chacune : l'extension de l'URL (`.deb`, `.rpm`, `.exe`), une signature non vide dont le `file:` (commentaire de confiance minisign, signature décodée du base64) est le nom de fichier de l'URL, décodé, et la version dans l'URL. **Il sort en 1 sur tout écart** (`MANQUANT`, `VIDE`, `MAUVAISE VERSION`, `MAUVAISE EXTENSION`, `DIFFÉRENT`, `INATTENDUE`) et finit par `VERDICT : OK` ou `VERDICT : ÉCHEC` : pas d'annonce de la release ni d'étape 10 avant un verdict OK. Un second argument (`verify-release.sh vX.Y.Z fichier.json`) contrôle un `latest.json` local au lieu de l'asset, pour éprouver les contrôles sur un fichier fabriqué.
|
||||
**Lire aussi l'étape « Generate latest.json » du journal du run** (même route web que ci-dessous) : `scripts/build-latest-json.sh` y nomme le paquet retenu pour chaque type (`build-latest-json: .rpm <- …`), puis le `latest.json` généré est affiché. Le script, le filtre `scripts/check-latest-json.jq` et le texte de l'étape « Publish » (avec un `curl` factice) passent par les tests de `check-frontend` à chaque PR ; l'appel du script par l'étape « Generate latest.json », le délimiteur aléatoire des notes et les vrais appels au registre ne s'exercent qu'au tag. Un manifeste refusé par le filtre arrête le job sur `ERROR: release-assets/latest.json failed scripts/check-latest-json.jq`, avant le `DELETE` : registre intact, et la release Forgejo reste créée sans mise à jour servie.
|
||||
Durée observée du run : 22 à 30 min sur les 12 dernières releases — poser le `Monitor` puis passer à autre chose.
|
||||
**Lire aussi le Node.js du run.** `release.yml` n'est exercé qu'au tag : une montée de Node (Node 20 → 24 par #376) ne se prouve que dans ce journal. Il passe par la route web (l'API v1 des journaux répond 404). Le `run_number` vient de `/actions/tasks` : sans paramètre `page`, la route rend toute la liste, la plus récente en tête, et ignore `limit` (avec `page`, elle pagine par 30 ou par `limit`, mesuré le 2026-10-12) :
|
||||
```bash
|
||||
TAG=vX.Y.Z
|
||||
RUN=$(curl -s -H "Authorization: token $(cat ~/.forgejo-token)" \
|
||||
"https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-resultat/actions/tasks" \
|
||||
| python3 -c "import json,sys; print(next(t['run_number'] for t in json.load(sys.stdin)['workflow_runs'] if t['workflow_id']=='release.yml' and t['head_branch']==sys.argv[1]))" "$TAG")
|
||||
curl -sL -H "Authorization: token $(cat ~/.forgejo-token)" \
|
||||
"https://git.lacompagniemaximus.com/maximus/Simpl-Resultat/actions/runs/$RUN/jobs/0/logs" \
|
||||
| grep -m1 -E 'Z v[0-9]+\.[0-9]+\.[0-9]+\s*$'
|
||||
```
|
||||
Attendu : `v24.x`. C'est la sortie de `node --version` dans l'étape « Install base tools and runtimes », seule ligne de ce format dans le journal (la v0.17.1, run 508, affichait `v20.20.2`). Toute autre majeure est à expliquer avant l'étape 10.
|
||||
10. **Post-CI — dérouler un vrai cycle de mise à jour** si la release touche `tauri-plugin-updater`, ses dépendances (`reqwest`/`rustls`/`rustls-webpki`), `release.yml`, ou la config `updater` de `tauri.conf.json` : [`docs/qa-update-cycle.md`](../../../docs/qa-update-cycle.md).
|
||||
|
||||
Inspecter `latest.json` prouve qu'il est **bien formé**, pas qu'il **installe**. Le reste de la chaîne — téléchargement TLS, vérification de signature, exécution de l'installeur, redémarrage — n'est exercé par aucun test : `cargo check`/`cargo test` prouvent que ce code compile, rien de plus. Une régression s'y voit chez l'utilisateur, au moment de la mise à jour.
|
||||
|
||||
|
|
@ -63,3 +86,11 @@ updated: 2026-07-27
|
|||
- 2026-07-01 — Documenter que le header FR est `## [Non publié]` (≠ `[Unreleased]`), pour éviter le faux diagnostic « changelog FR vide » lors de la migration. Source : session 5466da98.
|
||||
- 2026-07-13 — Étape 0 (pré-vol) : revalider le tip localement avant de tagger — `check.yml` ne tourne pas sur `main`, le tip mergé n'a jamais été vu par le CI ; vérifier `.claude/worktrees/` vide (vitest récurse sinon). Étape 9 (post-CI) : vérifier la release publiée — 7 artefacts attendus + contenu de `latest.json` (pilote l'auto-update) ; `status=success` ne suffit pas. Règle : tagger publie vers l'extérieur (updater automatique) → confirmer avec Max avant de tagger. Source : session fdda84cb (release v0.13.0).
|
||||
- 2026-07-27 — Étape 10 (post-CI) : dérouler un vrai cycle de mise à jour via `docs/qa-update-cycle.md` quand la release touche l'updater. Inspecter `latest.json` prouve qu'il est bien formé, pas qu'il installe : le téléchargement TLS, la vérification de signature, l'exécution de l'installeur et le redémarrage ne sont exercés par aucun test. Deux pièges qui font cocher sans vérifier — tester depuis la machine déjà à jour (`check()` répond « à jour ») et tester sans clé Base+ (auto-update verrouillé par édition, `notEntitled` avant toute requête). Source : session 50ac88d9 (#315, découvert en traitant les advisories `tar`/`rustls-webpki` de #310).
|
||||
- 2026-09-13 — Étape 0 (pré-vol) : cross-référence vers `/issue-license` quand la release modifie `FEATURE_TIERS` ou verrouille une fonctionnalité Free — émettre et distribuer les clés testeurs avant de tagger, sinon ils perdent l'auto-update. Source : session 690a6129 via /skills-review-inbox (draft 2026-09-06).
|
||||
- 2026-09-13 — Étape 0 : prérequis « clés testeurs saisies avant le tag » précisé (déclencheurs `FEATURE_TIERS`, gating, `auto-update` ; cas réel v0.15.0, deux clés Premium émises le jour même). Étape 9 : script `scripts/verify-release.sh` (7 artefacts par suffixe + contenu de `latest.json`) au lieu d'un script réécrit à chaque release ; durée de run observée 22-30 min. Source : session 690a6129 (release v0.15.0, run 402) via /skills-review-inbox (draft 2026-09-12).
|
||||
- 2026-10-10 — Étape 9 (post-CI) : lire `node --version` dans le journal du run de release (route web, seule ligne `vX.Y.Z` du journal). `release.yml` n'est exercé qu'au tag, donc la montée Node 20 → 24 de #376 ne se prouve qu'à la release suivante. Source : caveat de planification de #376 (milestone `planned-2026-10-10-release-deps`).
|
||||
- 2026-10-10 — Étape 9 (post-CI) : `verify-release.sh` exige les quatre clés de `latest.json` (`linux-x86_64`, `linux-x86_64-deb`, `linux-x86_64-rpm`, `windows-x86_64`), l'extension de chaque URL et le `file:` minisign de chaque signature, et sort en 1 sur tout écart (verdict en dernière ligne) ; lire l'étape « Generate latest.json » du journal. Source : #320 (milestone `planned-2026-10-10-release-deps`).
|
||||
- 2026-10-11 — Étape 9 (post-CI) : `latest.json` à trois clés, la clé générique `linux-x86_64` étant retirée (décision de Max sur #320 : ses derniers lecteurs, AppImage v0.4.3 à v0.4.5 et binaires hors paquet, ne savaient pas installer le `.deb` qu'elle servait). Le filtre d'avant le `DELETE` vit dans `scripts/check-latest-json.jq`, que les tests de `check-frontend` exercent à chaque PR, avec la comparaison des trois listes de clés. Source : revue de la PR #416.
|
||||
- 2026-10-11 — Étape 2 : rejouer le garde `windows-sys` de `check-rust.yml` (ADR 0023) juste après l'édition de `Cargo.lock`, puisque le commit de release n'a pas de PR et que `check-rust` ne le verrait qu'au lundi suivant. Commande éprouvée sur le lock de la pile (exit 0) et sur une arête dérivée (exit 1). Source : revue de la PR #415.
|
||||
- 2026-10-16 — Étape 0 : report d'un changement de matrice des éditions vers le tableau comparatif du site (`la-compagnie-maximus/src/data/editions.ts`), PR du site préparée avant le tag et déployée après la publication. Source : la-compagnie-maximus#180.
|
||||
- 2026-10-18 — Étape 9 (post-CI) : le filtre de `latest.json` est appelé avec `-s` (fichier vide ou à deux documents refusé sous jq 1.6, celle du conteneur), et le texte de l'étape « Publish » de `release.yml` est exécuté par `scripts/build-latest-json.test.mjs` ; `check-frontend` ne saute plus les PR qui ne touchent que `.claude/**` ou `release.yml`. Source : #419 (suivis de la revue de la PR #418).
|
||||
|
|
|
|||
113
.claude/skills/release/scripts/verify-release.sh
Executable file
113
.claude/skills/release/scripts/verify-release.sh
Executable file
|
|
@ -0,0 +1,113 @@
|
|||
#!/usr/bin/env bash
|
||||
# Step 9 of /release: verify the published Forgejo release for a tag —
|
||||
# the 7 expected assets and the content of latest.json (drives auto-update).
|
||||
# Exits 1 when any check fails (#320), with a VERDICT as last line.
|
||||
#
|
||||
# Usage: verify-release.sh vX.Y.Z [latest.json]
|
||||
# The optional second argument checks that local file instead of the
|
||||
# release's latest.json asset, to prove the checks on crafted files.
|
||||
set -uo pipefail
|
||||
TAG="${1:?usage: verify-release.sh vX.Y.Z [latest.json]}"
|
||||
LOCAL_LATEST="${2:-}"
|
||||
VERSION="${TAG#v}"
|
||||
TOKEN=$(cat ~/.forgejo-token)
|
||||
API="https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-resultat"
|
||||
WORK="${TMPDIR:-/tmp}/verify-release-${TAG}"; mkdir -p "$WORK"
|
||||
OUT="$WORK/latest.json"
|
||||
STATUS=0
|
||||
|
||||
echo "=== release ${TAG} ==="
|
||||
timeout 20 curl -s -H "Authorization: token $TOKEN" "${API}/releases/tags/${TAG}" > "$WORK/release.json"
|
||||
python3 - "$WORK/release.json" "$VERSION" <<'EOF' || STATUS=1
|
||||
import sys, json
|
||||
r = json.load(open(sys.argv[1])); version = sys.argv[2]
|
||||
if "id" not in r:
|
||||
print("PAS DE RELEASE :", r); sys.exit(1)
|
||||
print(f"name={r.get('name')!r} tag={r.get('tag_name')} draft={r.get('draft')} prerelease={r.get('prerelease')} assets={len(r.get('assets', []))}")
|
||||
names = {}
|
||||
for a in r.get("assets", []):
|
||||
n = a["name"]; s = a["size"]; names[n] = a["browser_download_url"]
|
||||
print(f" {n} {s/1_000_000:.1f} MB" if s > 100_000 else f" {n} {s} B")
|
||||
# Naming conventions vary by Tauri version — report by suffix rather than failing on exact names.
|
||||
kinds = {"exe": 0, "exe.sig": 0, "deb": 0, "deb.sig": 0, "rpm": 0, "rpm.sig": 0, "latest.json": 0}
|
||||
for n in names:
|
||||
for k in ("exe.sig", "deb.sig", "rpm.sig"):
|
||||
if n.endswith("." + k): kinds[k] += 1; break
|
||||
else:
|
||||
for k in ("exe", "deb", "rpm"):
|
||||
if n.endswith("." + k): kinds[k] += 1; break
|
||||
else:
|
||||
if n == "latest.json": kinds["latest.json"] += 1
|
||||
missing = [k for k, v in kinds.items() if v == 0]
|
||||
print("couverture:", kinds, "| MANQUANT:" if missing else "| complet", missing if missing else "")
|
||||
open(sys.argv[1] + ".latest-url", "w").write(names.get("latest.json") or "")
|
||||
sys.exit(1 if missing else 0)
|
||||
EOF
|
||||
|
||||
if [ -n "$LOCAL_LATEST" ]; then
|
||||
OUT="$LOCAL_LATEST"
|
||||
echo; echo "=== latest.json (fichier local $OUT, pas l'asset de la release) ==="
|
||||
else
|
||||
URL=$(cat "$WORK/release.json.latest-url" 2>/dev/null)
|
||||
if [ -z "$URL" ]; then echo "latest.json absent — arrêt"; echo "VERDICT : ÉCHEC"; exit 1; fi
|
||||
echo; echo "=== latest.json ($URL) ==="
|
||||
timeout 20 curl -sL "$URL" > "$OUT"
|
||||
fi
|
||||
python3 - "$OUT" "$VERSION" <<'EOF' || STATUS=1
|
||||
import sys, json, base64, binascii
|
||||
from urllib.parse import unquote, urlsplit
|
||||
j = json.load(open(sys.argv[1])); version = sys.argv[2]
|
||||
ok = True
|
||||
v = j.get("version"); pub = j.get("pub_date"); notes = j.get("notes", "")
|
||||
print(f"version={v} {'OK' if v == version else 'MAUVAISE VERSION'} | pub_date={pub}")
|
||||
ok &= v == version
|
||||
print(f"notes: {len(notes)} caractères — début: {notes[:120]!r}")
|
||||
|
||||
# The three keys (#320): tauri-plugin-updater looks up "{os}-{arch}-{installer}",
|
||||
# then "{os}-{arch}". Each with the extension its URL must end with. No generic
|
||||
# linux-x86_64 since 2026-10-11, so a manifest that still has it fails here as
|
||||
# INATTENDUE. Same set as scripts/build-latest-json.sh writes and
|
||||
# scripts/check-latest-json.jq checks: scripts/build-latest-json.test.mjs reads
|
||||
# this line and compares.
|
||||
EXPECTED = {"linux-x86_64-deb": ".deb", "linux-x86_64-rpm": ".rpm",
|
||||
"windows-x86_64": ".exe"}
|
||||
|
||||
def signed_file(sig):
|
||||
"""The file: field of the trusted comment, in the base64 minisign signature."""
|
||||
try:
|
||||
text = base64.b64decode(sig, validate=True).decode("utf-8")
|
||||
except (binascii.Error, ValueError):
|
||||
return None
|
||||
for line in text.splitlines():
|
||||
if line.startswith("trusted comment: ") and "\tfile:" in line:
|
||||
return line.split("\tfile:", 1)[1]
|
||||
return None
|
||||
|
||||
platforms = j.get("platforms", {})
|
||||
for plat in sorted(set(platforms) | set(EXPECTED)):
|
||||
if plat not in platforms:
|
||||
print(f" {plat}: MANQUANT"); ok = False; continue
|
||||
if plat not in EXPECTED:
|
||||
print(f" {plat}: clé INATTENDUE"); ok = False; continue
|
||||
p = platforms[plat]
|
||||
sig = p.get("signature") or ""; url = p.get("url") or ""
|
||||
name = unquote(urlsplit(url).path.rsplit("/", 1)[-1]) # file name, decoded
|
||||
ext = EXPECTED[plat]; signed = signed_file(sig) if sig else None
|
||||
checks = {
|
||||
"extension": name.endswith(ext),
|
||||
"signature": bool(sig),
|
||||
"file:": signed == name,
|
||||
"version": version in url,
|
||||
}
|
||||
ok &= all(checks.values())
|
||||
print(f" {plat}: url={name}"
|
||||
f" | extension {ext} {'OK' if checks['extension'] else 'MAUVAISE EXTENSION'}"
|
||||
f" | signature {'OK (' + str(len(sig)) + ' car.)' if sig else 'VIDE'}"
|
||||
f" | file: {'OK' if checks['file:'] else 'DIFFÉRENT (' + repr(signed) + ')'}"
|
||||
f" | url contient version={'oui' if checks['version'] else 'NON'}")
|
||||
sys.exit(0 if ok else 1)
|
||||
EOF
|
||||
|
||||
echo
|
||||
if [ "$STATUS" -eq 0 ]; then echo "VERDICT : OK"; else echo "VERDICT : ÉCHEC"; fi
|
||||
exit "$STATUS"
|
||||
|
|
@ -2,14 +2,14 @@ name: Security audit
|
|||
|
||||
# Daily RustSec coverage (#232).
|
||||
#
|
||||
# check-rust.yml only runs when src-tauri/ changes, which is roughly 1 PR in
|
||||
# 40 — without this workflow a new advisory published against an unchanged
|
||||
# dependency would go unnoticed for weeks.
|
||||
# check-rust.yml runs on PRs that change src-tauri/ and once a week on main,
|
||||
# and its cargo audit step never fails the job — without this workflow a new
|
||||
# advisory published against an unchanged dependency would go unnoticed.
|
||||
#
|
||||
# Runs without a Rust toolchain: cargo-audit only reads Cargo.lock, so the
|
||||
# binary is invoked directly instead of as a `cargo` subcommand. That keeps
|
||||
# this to ~1-2 min rather than the ~22 min a scheduled check-rust would cost
|
||||
# every day on a capacity-1 runner.
|
||||
# this to ~1 min, where a daily check-rust would hold the capacity-1 runner
|
||||
# for several minutes.
|
||||
#
|
||||
# Note: PATH is deliberately NOT overridden at job level (unlike check-rust,
|
||||
# which needs /root/.cargo/bin for the toolchain) so that the install dir
|
||||
|
|
@ -27,6 +27,10 @@ on:
|
|||
- cron: '0 6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
# What this key does on Forgejo 8.0.3: see the comment on `concurrency` in
|
||||
# check-rust.yml (#376). That measurement covers push and pull_request runs;
|
||||
# this workflow only runs on schedule and workflow_dispatch, which it does
|
||||
# not cover.
|
||||
concurrency:
|
||||
group: ci-audit-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
|
@ -39,14 +43,15 @@ jobs:
|
|||
runs-on: ubuntu
|
||||
container: ubuntu:22.04
|
||||
steps:
|
||||
- name: Install Node.js 20
|
||||
- name: Install Node.js 24
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates git tar gzip
|
||||
# Node.js is required by actions/checkout and taiki-e/install-action
|
||||
# (JavaScript actions need `node` in the container PATH).
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
# Node.js is required by actions/checkout, a JavaScript action that
|
||||
# needs `node` in the container PATH (taiki-e/install-action is a
|
||||
# composite action).
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
|
||||
apt-get install -y nodejs
|
||||
node --version
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ name: PR Check — Frontend
|
|||
# running it is not. A root-level config file added later would drop out of an
|
||||
# allowlist without anyone noticing; here it fails safe.
|
||||
#
|
||||
# .claude/** and release.yml are not ignored (#419): scripts/build-latest-json.test.mjs
|
||||
# reads .claude/skills/release/scripts/verify-release.sh and runs the text of
|
||||
# release.yml's "Publish latest.json to package registry" step, so a PR that
|
||||
# touches only one of them must still run it.
|
||||
#
|
||||
# No `branches:` filter — see check-rust.yml.
|
||||
|
||||
on:
|
||||
|
|
@ -18,14 +23,14 @@ on:
|
|||
- 'reports/**'
|
||||
- 'tasks/**'
|
||||
- '.github/**'
|
||||
- '.claude/**'
|
||||
- '.forgejo/workflows/check-rust.yml'
|
||||
- '.forgejo/workflows/audit.yml'
|
||||
- '.forgejo/workflows/release.yml'
|
||||
- '*.md'
|
||||
- 'LICENSE'
|
||||
|
||||
# Distinct group from the rust workflow — see check-rust.yml.
|
||||
# Same concurrency setup as check-rust.yml (own group, cancel-in-progress),
|
||||
# whose comment says what it does on Forgejo 8.0.3 (#376) and why each
|
||||
# workflow has its own group.
|
||||
concurrency:
|
||||
group: ci-frontend-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
|
@ -38,22 +43,26 @@ jobs:
|
|||
runs-on: ubuntu
|
||||
container: ubuntu:22.04
|
||||
steps:
|
||||
- name: Install Node.js 20
|
||||
- name: Install Node.js 24
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends curl ca-certificates git
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
# jq: scripts/build-latest-json.test.mjs runs the script that writes
|
||||
# the release's latest.json, and that script needs it (#320).
|
||||
apt-get install -y --no-install-recommends curl ca-certificates git jq
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
|
||||
apt-get install -y nodejs
|
||||
node --version
|
||||
npm --version
|
||||
jq --version
|
||||
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
# No npm cache step here, deliberately — same reason as the cargo caches
|
||||
# in check-rust.yml: the restore misses and the save times out against
|
||||
# the runner's cache server (#234), which cost ~23s per run for nothing.
|
||||
# Restore it together with rust-cache once #234 is fixed.
|
||||
# No npm cache step, by decision (#234). The runner's cache works since
|
||||
# 2026-10-03, but it keeps what a pull_request run saves private to that
|
||||
# PR, and this workflow runs on PRs only: a cache would serve later
|
||||
# pushes to the same PR, while `npm ci` takes 9s here (run 456) and
|
||||
# each PR's first run would pay for the save.
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ name: PR Check — Rust
|
|||
|
||||
# Rust half of the former check.yml (split in #232).
|
||||
#
|
||||
# Only runs when Rust actually changes. On this repo roughly 1 PR in 40 touches
|
||||
# src-tauri/, and the runner has capacity 1 — jobs queue instead of running in
|
||||
# parallel, so every minute spent here is a minute the next PR waits.
|
||||
# Runs on PRs that change Rust, plus once a week on main to refresh the shared
|
||||
# cache (see the note on the triggers). The runner has capacity 1 — jobs queue
|
||||
# instead of running in parallel, so every minute spent here is a minute the
|
||||
# next PR waits.
|
||||
#
|
||||
# No `branches:` filter on purpose. `branches: [main]` never matched a PR
|
||||
# stacked on top of another feature branch, which is what /autopilot produces:
|
||||
|
|
@ -19,10 +20,53 @@ on:
|
|||
# (rustflags, linker, target dir) would change the Rust build, and an
|
||||
# exact-path entry would let it skip Rust CI unnoticed.
|
||||
- '.cargo/**'
|
||||
# The weekly run is what feeds the cache to pull requests (#234). The runner
|
||||
# (forgejo-runner v12.7.0) isolates what a pull_request run writes to that
|
||||
# PR's ref: later pushes to the same PR read it back, other PRs never see it.
|
||||
# A PR run falls back to the shared, non-isolated entries only when nothing
|
||||
# matches in its own scope. Scheduled and dispatched runs write to the shared
|
||||
# scope, which makes them this workflow's only writers of it (a merged PR's
|
||||
# `closed` event would be another, but this workflow does not subscribe to
|
||||
# it).
|
||||
#
|
||||
# The runner deletes an entry unused for 7 days or created more than 30 days
|
||||
# ago, and keeps only the newest entry per key, whatever its scope; the next
|
||||
# Monday run saves a fresh one. Cold runs that do not mean #234 is back (a
|
||||
# timeout would):
|
||||
# - a new stable Rust (the toolchain floats on `stable`) or a change to this
|
||||
# job's `env:`: the key hashes rustc and the CARGO*, RUST*, CC*, CFLAGS*,
|
||||
# CXX* and CMAKE* variables. Cold until the next Monday;
|
||||
# - a PR run that saves under the shared entry's key evicts it for every PR
|
||||
# until the next Monday. It saves when its own scope holds an older entry
|
||||
# for another Cargo.lock, served before the shared one: a PR that bumped a
|
||||
# dependency, then went back to main's lockfile. Two open PRs on the same
|
||||
# lockfile, different from main's, evict each other the same way;
|
||||
# - PR runs once an entry that only got exact hits, hence never re-saved,
|
||||
# reaches 30 days;
|
||||
# - after a week without any cache request, the Monday restore can fail with
|
||||
# a 404: the lookup does not refresh the entry, and the cleanup it triggers
|
||||
# may delete it (forgejo/runner#1741). The run then saves a fresh entry.
|
||||
# workflow_dispatch re-seeds by hand.
|
||||
schedule:
|
||||
# Mondays 06:30 UTC, after audit.yml (06:00 UTC).
|
||||
- cron: '30 6 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
# Cancel obsolete runs (e.g. on force-push) so only the latest commit runs.
|
||||
# Distinct from the frontend group: a shared group would make the two
|
||||
# workflows cancel each other on a PR that touches both.
|
||||
# What this key does on Forgejo 8.0.3 / runner v12.7.0, as measured on
|
||||
# 2026-10-02 (la-compagnie-maximus docs/forgejo-ops.md, sections "Le schéma du
|
||||
# workflow est validé par le runner" and "Un push annule le run du push
|
||||
# précédent"):
|
||||
# - it passes the runner's schema validation, but what it adds has not been
|
||||
# isolated;
|
||||
# - without it, Forgejo cancels a `push` run when a newer one of the same
|
||||
# workflow is created on the same ref, whether the older run's job was still
|
||||
# queued or already running;
|
||||
# - no pull_request (or pull_request_sync) job has been cancelled by a later
|
||||
# run: on a PR, a force-push does not cancel the obsolete run.
|
||||
# Kept anyway (#376): harmless here, and it keeps its GitHub Actions meaning
|
||||
# should these workflows run where it is honoured (GitHub, a later Forgejo).
|
||||
# There, a group shared with the frontend workflow would make the two cancel
|
||||
# each other on a PR that touches both, hence the distinct group.
|
||||
concurrency:
|
||||
group: ci-rust-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
|
@ -37,22 +81,27 @@ jobs:
|
|||
env:
|
||||
PATH: /root/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
CARGO_TERM_COLOR: always
|
||||
# Nothing persists between runs (see the caching note below), so
|
||||
# incremental artifacts get written and never reused — pure overhead.
|
||||
# Test debug info is dead weight here for the same reason.
|
||||
# rust-cache keeps the dependencies and drops this crate's own artifacts
|
||||
# before saving, so incremental data would never be reused — pure
|
||||
# overhead — and test debug info would only make the cached target/
|
||||
# bigger.
|
||||
CARGO_INCREMENTAL: 0
|
||||
CARGO_PROFILE_TEST_DEBUG: 0
|
||||
steps:
|
||||
- name: Install system dependencies, Node.js and Rust
|
||||
run: |
|
||||
apt-get update
|
||||
# zstd: the cache archives are compressed with it when present,
|
||||
# faster and smaller than the gzip fallback.
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl wget git ca-certificates build-essential pkg-config \
|
||||
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libssl-dev \
|
||||
libdbus-1-dev
|
||||
# Node.js is required by actions/checkout and taiki-e/install-action
|
||||
# (they are JavaScript actions and need `node` in the container PATH).
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
libdbus-1-dev zstd
|
||||
# Node.js runs the JavaScript actions: actions/checkout (node20) and
|
||||
# Swatinem/rust-cache (node24 since v2.9). The runner starts them
|
||||
# with whatever `node` the container has, so install the newest
|
||||
# runtime they declare. taiki-e/install-action is a composite action.
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
|
||||
apt-get install -y nodejs
|
||||
# Rust toolchain
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||
|
|
@ -63,11 +112,19 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
# No actions/cache step here, deliberately. The job container cannot
|
||||
# reach the runner's cache server (#234): the restore times out into a
|
||||
# miss AND the save times out, so the cache cost ~7 min per run and
|
||||
# returned nothing. Bring caching back through Swatinem/rust-cache once
|
||||
# #234 is fixed — not before, it shares the same backend.
|
||||
# Caches ~/.cargo (registry, git, bin) and src-tauri/target, keyed on the
|
||||
# rustc version, the job's CARGO*/RUST* environment, the dependencies
|
||||
# declared in the Cargo manifests and lockfile (not the app's own
|
||||
# version), and this job. Saved only when the job succeeds and the key
|
||||
# was not an exact hit. Served by
|
||||
# the runner's cache proxy on port 28100, which ufw opens to the Docker
|
||||
# job networks only; until 2026-10-03 every restore and save timed out
|
||||
# there (#234, la-compagnie-maximus docs/forgejo-ops.md). What a PR run
|
||||
# saves stays private to that PR: see the note on the triggers.
|
||||
- name: Rust cache
|
||||
uses: https://github.com/Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "src-tauri -> target"
|
||||
|
||||
- name: cargo check
|
||||
run: cargo check --manifest-path src-tauri/Cargo.toml --all-targets
|
||||
|
|
@ -120,19 +177,159 @@ jobs:
|
|||
checks=$((checks + 1))
|
||||
done
|
||||
done
|
||||
# Canary: a crate known to be present. If this stops being found, the
|
||||
# loop above is broken and its silence means nothing.
|
||||
# Canary: a crate known to be present on the Linux target, and only
|
||||
# there. If this stops being found, the loop above is broken and its
|
||||
# silence means nothing. webkit2gtk is Tauri's Linux webview (wry): a
|
||||
# Tauri v2 Linux build cannot drop it, and as a Linux-only dependency
|
||||
# it also proves that --target resolves target-conditional edges, which
|
||||
# the per-target check above relies on. It replaced tar, a Linux-only
|
||||
# dependency of the updater until #455 built it without its zip
|
||||
# feature.
|
||||
canary=$(cargo tree --manifest-path src-tauri/Cargo.toml --locked \
|
||||
-i tar --target x86_64-unknown-linux-gnu 2>/dev/null) || true
|
||||
-i webkit2gtk --target x86_64-unknown-linux-gnu 2>/dev/null) || true
|
||||
if [ -z "$canary" ]; then
|
||||
echo "FAIL: canary 'tar' was not found although it is a known dependency. The guard is not proving anything."
|
||||
echo "FAIL: canary 'webkit2gtk' was not found although it is a known dependency. The guard is not proving anything."
|
||||
rc=1
|
||||
else
|
||||
echo "ok: canary 'tar' found for x86_64-unknown-linux-gnu"
|
||||
echo "ok: canary 'webkit2gtk' found for x86_64-unknown-linux-gnu"
|
||||
fi
|
||||
echo "Suppression guard: $checks checks, exit $rc"
|
||||
exit $rc
|
||||
|
||||
# Pin guard for windows-sys (#345). Seven crates in the lockfile accept
|
||||
# windows-sys through a wide bound, so cargo may point each of them at
|
||||
# any version the graph already holds. All seven point at 0.61.2, the
|
||||
# state every Windows release has shipped with since v0.14.0. An update
|
||||
# such as `cargo update -p hyper` re-resolves the seven edges down to
|
||||
# 0.48/0.52/0.59, and cargo reports only the packages it bumped, never
|
||||
# the edges: the Windows build then compiles five windows-sys versions
|
||||
# instead of three, and nothing says so. Which updates do it cannot be
|
||||
# told from their names (#348), so the step guards the resolved version.
|
||||
# docs/adr/0023-windows-sys-pinning.md holds the measurements, the exit
|
||||
# condition and what to do when this step fails.
|
||||
#
|
||||
# awk reads Cargo.lock: mawk is part of the ubuntu:22.04 base image,
|
||||
# while python3 is only here as a transitive dependency of the apt
|
||||
# packages installed above (run 517), which nothing guarantees. Its exit
|
||||
# code is tested apart from its output: an empty output already fails the
|
||||
# checks below (version set, missing blocks, canary), but an awk that
|
||||
# fails after printing every fact would pass without that test. As in the
|
||||
# step above, every check echoes its result, including the passing ones,
|
||||
# and a canary must be read and checked, or the loop's silence proves
|
||||
# nothing.
|
||||
#
|
||||
# No cargo call, so neither the warm index nor --locked matters here: the
|
||||
# step reads the lockfile as cargo check left it, which is the committed
|
||||
# one unless cargo had to update it.
|
||||
- name: Verify windows-sys pinning
|
||||
run: |
|
||||
set -u
|
||||
# The guarded list. Why 0.61.2, why these versions, and when either
|
||||
# may change: docs/adr/0023-windows-sys-pinning.md. Edit CRATES, PIN
|
||||
# or EXPECTED only on the proof that ADR asks for, never to turn this
|
||||
# step green.
|
||||
CRATES="colored dirs-sys errno rustix rustls-platform-verifier tempfile winapi-util"
|
||||
PIN="0.61.2"
|
||||
# Every windows-sys version in the lockfile, all targets included,
|
||||
# as of 2026-10-10.
|
||||
EXPECTED="0.45.0 0.48.0 0.52.0 0.59.0 0.60.2 0.61.2"
|
||||
CANARY="tempfile"
|
||||
LOCK="src-tauri/Cargo.lock"
|
||||
ADR="docs/adr/0023-windows-sys-pinning.md"
|
||||
rc=0
|
||||
checks=0
|
||||
canary_checked=0
|
||||
# One fact per line: "wsys <version>" for each windows-sys package,
|
||||
# and "block <crate> <version> [<target>...]" for each [[package]]
|
||||
# block of a crate in CRATES, with one <target> per windows-sys
|
||||
# edge: the version the edge names, or "-" for a bare edge (cargo
|
||||
# drops the version when the lockfile holds a single windows-sys).
|
||||
# A " (registry+...)" source suffix on an edge is dropped.
|
||||
facts=$(awk -v crates="$CRATES" '
|
||||
function flush( i, line) {
|
||||
if (inpkg && name == "windows-sys") print "wsys", version
|
||||
if (inpkg && (name in wanted)) {
|
||||
line = "block " name " " version
|
||||
for (i = 1; i <= n; i++) line = line " " target[i]
|
||||
print line
|
||||
}
|
||||
inpkg = 0; name = ""; version = ""; n = 0
|
||||
}
|
||||
BEGIN { k = split(crates, list, " "); for (i = 1; i <= k; i++) wanted[list[i]] = 1 }
|
||||
/^\[\[package\]\]$/ { flush(); inpkg = 1; next }
|
||||
/^\[/ { flush(); next }
|
||||
inpkg && /^name = "/ { name = $3; gsub(/"/, "", name) }
|
||||
inpkg && /^version = "/ { version = $3; gsub(/"/, "", version) }
|
||||
inpkg && /^[ \t]*"windows-sys[ "]/ {
|
||||
s = $0; sub(/^[ \t]*"/, "", s); sub(/".*$/, "", s)
|
||||
m = split(s, f, " "); target[++n] = (m >= 2 ? f[2] : "-")
|
||||
}
|
||||
END { flush() }
|
||||
' "$LOCK") && awk_rc=0 || awk_rc=$?
|
||||
if [ "$awk_rc" -ne 0 ]; then
|
||||
echo "FAIL: awk exited $awk_rc reading $LOCK, so nothing was checked."
|
||||
echo "windows-sys pinning: $checks checks, exit 1"
|
||||
exit 1
|
||||
fi
|
||||
# The exact set of windows-sys versions, duplicates kept: a version
|
||||
# that appears or disappears is a decision, not a side effect.
|
||||
found=$(printf '%s\n' "$facts" | awk '$1 == "wsys" { print $2 }' | sort | paste -sd ' ' -)
|
||||
want=$(printf '%s\n' $EXPECTED | sort | paste -sd ' ' -)
|
||||
checks=$((checks + 1))
|
||||
if [ "$found" = "$want" ]; then
|
||||
echo "ok: windows-sys versions in the lockfile: $found"
|
||||
else
|
||||
echo "FAIL: windows-sys versions in the lockfile are \"$found\", expected exactly \"$want\". A version was added or removed: follow $ADR before editing EXPECTED."
|
||||
rc=1
|
||||
fi
|
||||
# Every edge of every block of each guarded crate must name PIN. A
|
||||
# bare edge passes only when the lockfile's one windows-sys is PIN.
|
||||
for crate in $CRATES; do
|
||||
blocks=$(printf '%s\n' "$facts" | awk -v c="$crate" '$1 == "block" && $2 == c')
|
||||
if [ -z "$blocks" ]; then
|
||||
checks=$((checks + 1))
|
||||
echo "FAIL: no [[package]] block named $crate in $LOCK (renamed, or gone from the graph?). See $ADR."
|
||||
rc=1
|
||||
continue
|
||||
fi
|
||||
while read -r kind name version targets; do
|
||||
if [ -z "$targets" ]; then
|
||||
checks=$((checks + 1))
|
||||
echo "FAIL: $crate $version has no windows-sys edge left. See $ADR."
|
||||
rc=1
|
||||
continue
|
||||
fi
|
||||
for target in $targets; do
|
||||
checks=$((checks + 1))
|
||||
if [ "$target" = "$PIN" ]; then
|
||||
echo "ok: $crate $version -> windows-sys $PIN"
|
||||
elif [ "$target" != "-" ]; then
|
||||
echo "FAIL: $crate $version -> windows-sys $target, pinned at $PIN. See $ADR."
|
||||
rc=1
|
||||
elif [ "$found" = "$PIN" ]; then
|
||||
echo "ok: $crate $version -> windows-sys (bare edge: the lockfile's only windows-sys is $PIN)"
|
||||
else
|
||||
echo "FAIL: $crate $version -> windows-sys with no version, while the lockfile holds \"$found\". See $ADR."
|
||||
rc=1
|
||||
fi
|
||||
if [ "$crate" = "$CANARY" ]; then
|
||||
canary_checked=1
|
||||
fi
|
||||
done
|
||||
done <<<"$blocks"
|
||||
done
|
||||
# Canary: the loop must have read and checked an edge of a crate
|
||||
# known to carry one. Otherwise its silence proves nothing.
|
||||
checks=$((checks + 1))
|
||||
if [ "$canary_checked" -eq 1 ]; then
|
||||
echo "ok: canary '$CANARY' found, its windows-sys edge read and checked above"
|
||||
else
|
||||
echo "FAIL: canary '$CANARY' was not read and checked. The guard is not proving anything."
|
||||
rc=1
|
||||
fi
|
||||
echo "windows-sys pinning: $checks checks, exit $rc"
|
||||
exit $rc
|
||||
|
||||
- name: cargo test
|
||||
run: cargo test --manifest-path src-tauri/Cargo.toml --all-targets
|
||||
|
||||
|
|
|
|||
|
|
@ -16,8 +16,14 @@ jobs:
|
|||
run: |
|
||||
apt-get update
|
||||
apt-get install -y curl wget git sudo ca-certificates gnupg
|
||||
# Install Node.js 20
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
# Install Node.js 24 (#376). Trust note, kept on purpose without a
|
||||
# pinned tarball: NodeSource's script runs as root in this
|
||||
# container, and the Node.js it installs then runs `npx tauri build`
|
||||
# with TAURI_SIGNING_PRIVATE_KEY in its environment. A compromised
|
||||
# script or package would reach the updater signing key, and the
|
||||
# publishing tokens of the later steps. See docs/architecture.md
|
||||
# (CI/CD, release.yml).
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
|
||||
apt-get install -y nodejs
|
||||
# Install Rust
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
|
|
@ -81,59 +87,31 @@ jobs:
|
|||
NOTES=$(sed -n "/^## ${TAG}/,/^## /{/^## ${TAG}/d;/^## /d;p}" CHANGELOG.md)
|
||||
fi
|
||||
NOTES=$(echo "$NOTES" | sed -e '/./,$!d' -e :a -e '/^\n*$/{$d;N;ba}')
|
||||
# Random delimiter (#320): a changelog line equal to a fixed one
|
||||
# would end the value there and turn the lines after it into step
|
||||
# outputs.
|
||||
DELIM="CHANGELOG_EOF_$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')"
|
||||
{
|
||||
echo "notes<<CHANGELOG_EOF"
|
||||
echo "notes<<${DELIM}"
|
||||
echo "$NOTES"
|
||||
echo "CHANGELOG_EOF"
|
||||
echo "${DELIM}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate latest.json
|
||||
env:
|
||||
CHANGELOG_NOTES: ${{ steps.changelog.outputs.notes }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
PUB_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
BASE_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${TAG}"
|
||||
|
||||
LINUX_SIG=""
|
||||
LINUX_DEB=""
|
||||
WINDOWS_SIG=""
|
||||
WINDOWS_EXE=""
|
||||
|
||||
for f in release-assets/*.deb.sig; do
|
||||
[ -f "$f" ] && LINUX_SIG=$(cat "$f")
|
||||
done
|
||||
for f in release-assets/*.deb; do
|
||||
[ -f "$f" ] && LINUX_DEB=$(basename "$f")
|
||||
done
|
||||
for f in release-assets/*-setup.exe.sig; do
|
||||
[ -f "$f" ] && WINDOWS_SIG=$(cat "$f")
|
||||
done
|
||||
for f in release-assets/*-setup.exe; do
|
||||
[ -f "$f" ] && WINDOWS_EXE=$(basename "$f")
|
||||
done
|
||||
|
||||
PLATFORMS="{}"
|
||||
if [ -n "$LINUX_SIG" ] && [ -n "$LINUX_DEB" ]; then
|
||||
PLATFORMS=$(echo "$PLATFORMS" | jq \
|
||||
--arg sig "$LINUX_SIG" \
|
||||
--arg url "${BASE_URL}/${LINUX_DEB}" \
|
||||
'. + {"linux-x86_64": {"signature": $sig, "url": $url}}')
|
||||
fi
|
||||
if [ -n "$WINDOWS_SIG" ] && [ -n "$WINDOWS_EXE" ]; then
|
||||
PLATFORMS=$(echo "$PLATFORMS" | jq \
|
||||
--arg sig "$WINDOWS_SIG" \
|
||||
--arg url "${BASE_URL}/${WINDOWS_EXE}" \
|
||||
'. + {"windows-x86_64": {"signature": $sig, "url": $url}}')
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
--arg version "$VERSION" \
|
||||
--arg notes "$CHANGELOG_NOTES" \
|
||||
--arg pub_date "$PUB_DATE" \
|
||||
--argjson platforms "$PLATFORMS" \
|
||||
'{version: $version, notes: $notes, pub_date: $pub_date, platforms: $platforms}' \
|
||||
# Three keys: one per Linux package (.deb, .rpm) and the NSIS
|
||||
# installer, no generic linux-x86_64 key (#320); fails unless it finds
|
||||
# exactly one .deb, .rpm and -setup.exe, each with a non-empty .sig.
|
||||
# Notes reach jq as a file, as data.
|
||||
# Called as scripts/build-latest-json.test.mjs calls it.
|
||||
printf '%s' "$CHANGELOG_NOTES" > /tmp/release-notes.md
|
||||
bash -e -o pipefail scripts/build-latest-json.sh \
|
||||
release-assets \
|
||||
"${GITHUB_REF_NAME#v}" \
|
||||
/tmp/release-notes.md \
|
||||
"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF_NAME}" \
|
||||
> release-assets/latest.json
|
||||
|
||||
echo "Generated latest.json:"
|
||||
|
|
@ -155,7 +133,7 @@ jobs:
|
|||
## Installation
|
||||
|
||||
**Windows** : Téléchargez le fichier \`.exe\` ci-dessous.
|
||||
**Linux** : Téléchargez le fichier \`.deb\` ou \`.AppImage\` ci-dessous."
|
||||
**Linux** : Téléchargez le fichier \`.deb\` (Debian, Ubuntu) ou \`.rpm\` (Fedora, openSUSE) ci-dessous."
|
||||
|
||||
# Delete existing release for this tag (if any) to allow re-creation
|
||||
EXISTING=$(curl -s "${API_URL}/repos/${REPO}/releases/tags/${TAG}" \
|
||||
|
|
@ -214,6 +192,22 @@ jobs:
|
|||
env:
|
||||
FORGEJO_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
run: |
|
||||
# This text is run by scripts/build-latest-json.test.mjs, with a fake
|
||||
# curl: keep it self-contained, no workflow expressions (dollar,
|
||||
# double brace) in run:, pass values through env: (#419).
|
||||
#
|
||||
# Check the manifest before the DELETE below (#320): once the old
|
||||
# version is deleted, installed apps find no update until a valid
|
||||
# one is uploaded. A failed check stops the job, registry untouched.
|
||||
# The filter lives in scripts/check-latest-json.jq; -s makes it
|
||||
# refuse an empty file or one with two documents (#419).
|
||||
if ! jq -e -s --arg version "${GITHUB_REF_NAME#v}" \
|
||||
-f scripts/check-latest-json.jq release-assets/latest.json > /dev/null; then
|
||||
echo "ERROR: release-assets/latest.json failed scripts/check-latest-json.jq, registry left as is:"
|
||||
cat release-assets/latest.json
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# DELETE uses API v1, PUT uses the package upload API
|
||||
DELETE_URL="${GITHUB_SERVER_URL}/api/v1/packages/${GITHUB_REPOSITORY_OWNER}/generic/simpl-resultat/latest"
|
||||
UPLOAD_URL="${GITHUB_SERVER_URL}/api/packages/${GITHUB_REPOSITORY_OWNER}/generic/simpl-resultat/latest"
|
||||
|
|
|
|||
111
CHANGELOG.fr.md
111
CHANGELOG.fr.md
|
|
@ -2,6 +2,117 @@
|
|||
|
||||
## [Non publié]
|
||||
|
||||
## [0.17.2] - 2026-10-18
|
||||
|
||||
### Ajouté
|
||||
|
||||
- Import : un avertissement nomme les fichiers CSV posés directement dans le dossier d'import, qui sont ignorés (#427).
|
||||
- Parcours de démarrage : deux étapes de visite, « Parcourir les catégories et les mots-clés » (avant le classement) et « Parcourir les rapports » (en dernier), cochées par « J'ai compris » ; un profil qui avait terminé le parcours les trouve déjà faites ; leurs cartes, sur les pages Catégories et Rapports, expliquent les mots-clés et le hub des rapports (#425, #432).
|
||||
|
||||
### Modifié
|
||||
|
||||
- Le champ « Catégorie parente » du formulaire de catégorie et le champ « Catégorie » du dialogue « Nouveau mot-clé » (clic droit sur une transaction) acceptent désormais la saisie, comme la cellule de catégorie de la page Transactions : tapez quelques lettres pour filtrer la liste, qui montre la hiérarchie avec son indentation et les noms des catégories standard dans votre langue. La liste des parents ne propose plus la catégorie en cours d'édition, ses propres sous-catégories, ni un parent qui créerait un quatrième niveau ; un parent déjà enregistré reste affiché. Le dialogue de mot-clé propose les mêmes catégories que la page Transactions (actives et autorisées à la saisie) et ne garde la catégorie pré-remplie que si elle en fait partie ; ses deux champs sont placés au-dessus de la liste défilante des correspondances, pour que la liste de catégories ouverte ne soit jamais coupée (#423).
|
||||
- Une seule copie est gardée par profil dans le dossier `migration-backups` : la copie prise avant une mise à jour de la base est désormais refaite à chaque mise à jour, et une fois la mise à jour réussie, les copies plus anciennes de ce profil sont supprimées. Une copie laissée par une mise à jour interrompue n'est plus réutilisée telle quelle, puisqu'elle précédait ce que vous aviez saisi depuis. Quand une mise à jour s'arrête, le Journal nomme désormais la copie prise avant elle (#395).
|
||||
- Import : l'état vide montre la structure attendue (un dossier parent, un sous-dossier par compte), et les cartes du parcours demandent de choisir le dossier parent et de vérifier qu'une dépense compte parmi les sorties (#422).
|
||||
- Parcours de démarrage : la carte Transactions explique les mots-clés et propose « Ajouter comme mot-clé » ; la carte Budget précise que seules les catégories du dernier niveau acceptent un montant et ce que fait la colonne Annuel (#430).
|
||||
- La répartition d'une transaction sur plusieurs catégories (bouton Répartition de la page Transactions) fait désormais partie de l'édition Base, comme l'écran de déverrouillage le promettait déjà. En édition Gratuite, le bouton garde sa place, porte un cadenas et ouvre l'écran de déverrouillage au lieu de la fenêtre de répartition, pour toute ligne. Les répartitions déjà faites restent dans vos données : elles sont toujours comptées dans les rapports et le tableau de bord, et toujours signalées dans la liste (#369).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- La page d'erreur du démarrage ne reste plus affichée quand le profil finit de s'ouvrir après le délai de 10 secondes : l'application s'affiche dès que le profil est ouvert (#395).
|
||||
- Le petit badge à côté du bouton « Récupérer le prix » d'un titre boursier, dans le Bilan, affiche désormais « non garanti » au lieu de l'anglais « best-effort » (#389).
|
||||
- Les installations faites depuis le paquet `.rpm` reçoivent désormais les mises à jour automatiques. Le manifeste de mise à jour ne listait que le `.deb`, qu'une installation `.rpm` téléchargeait puis refusait ; il liste maintenant chaque paquet Linux sous sa propre clé. Les installations `.rpm` existantes n'ont rien à réinstaller : elles recevront la prochaine version (vérifié sur le paquet v0.17.1 publié). Les instructions de téléchargement de la page de release ne mentionnent plus d'AppImage, qui n'est pas construite, et nomment désormais le `.rpm`. Le manifeste n'a plus d'entrée Linux générique : les AppImage des versions 0.4.3 à 0.4.5, qui ne savaient pas installer le `.deb` qu'elle désignait et en étaient cassées, affichent désormais une erreur à la vérification des mises à jour ; installez plutôt le paquet `.deb` ou `.rpm` (#320).
|
||||
- Budget : une sous-catégorie dépliée s'affiche sous sa catégorie parente, et non plus en fin de groupe. Avec les sous-totaux en bas, chaque sous-total suit ses propres lignes, dans la grille Budget comme dans les rapports Budget vs réel et Comparables (#424).
|
||||
- Import : le nom d'une source est celui de son sous-dossier, en lecture seule ; une source renommée à la configuration n'est plus prise pour une nouvelle source au prochain import (#426).
|
||||
- Aide et guide : le Budget décrit la saisie dans la colonne Annuel (plus de bouton de répartition) et la saisie limitée aux catégories du dernier niveau ; les Rapports ont cinq cartes et plus d'onglets ; l'ajout d'un mot-clé depuis une transaction passe par le clic droit (#431).
|
||||
- La suppression d’un profil, d’une catégorie, d’un ajustement, d’un gabarit de budget ou d’une catégorie du Bilan, et le retrait de la clé de licence, attendent maintenant votre réponse à la confirmation. Jusqu’ici, l’action partait dès l’affichage de la confirmation et « Annuler » n’empêchait rien : un profil supprimé perdait ses données. Les boutons de la confirmation suivent désormais la langue de l’app (#421).
|
||||
|
||||
### Sécurité
|
||||
|
||||
- Mise à jour de dépendances npm pour corriger les huit vulnérabilités signalées par `npm audit` (cinq élevées, trois modérées), par le seul fichier de verrouillage : chaque nouvelle version reste dans la plage que `package.json` déclarait déjà. `react-router-dom` et `react-router` 7.18.1 → 7.18.4 corrigent GHSA-qwww-vcr4-c8h2 (CSRF en mode React Server Components), l'advisory laissée en l'état jusqu'ici parce qu'en sortir semblait demander une migration vers react-router v8 ; la 7.18.2 l'a corrigée. Le reste est de l'outillage de build et de test, jamais livré dans l'application : `vitest` et `@vitest/mocker` 4.1.9 → 4.1.11, `browserslist` 4.28.4 → 4.29.3, `baseline-browser-mapping` 2.10.41 → 2.11.28, `nanoid` 3.3.16 → 3.3.20 et `source-map-js` 1.2.1 → 1.2.2. Aucune de ces advisories n'était atteignable dans l'application livrée, qui exécute react-router dans sa propre fenêtre, sans serveur. De toutes ces mises à jour, seule celle de react-router modifie l'application construite : elle refuse désormais une navigation vers une autre origine, ce que l'application ne fait jamais, donc la navigation se comporte comme avant (#358).
|
||||
- Mise à jour de la dépendance Rust `rustls` (0.23.45 → 0.23.46), corrigeant RUSTSEC-2026-0346, RUSTSEC-2026-0347, RUSTSEC-2026-0348 et RUSTSEC-2026-0349. `rustls` est la couche TLS de la vérification et du téléchargement des mises à jour automatiques, atteinte via `tauri-plugin-updater` ; les appels de licence, d'authentification, de cours et de rétroaction de l'application passent par un autre moteur TLS. Aucune des quatre ne s'applique à l'usage qu'en fait l'application : aucun magasin de sessions maison ne peut journaliser de secret de session (0346), l'Encrypted Client Hello n'est jamais activé (0347), la décompression Brotli des certificats, optionnelle, n'est pas compilée (0348), et le fournisseur cryptographique `ring` ne propose aucun échange de clés post-quantique qui pourrait être rabattu (0349). Mise à jour quand même, plutôt que contournée par un raisonnement, puisqu'un correctif existe. Rien ne change dans le code de l'application elle-même — seul le fichier de verrouillage bouge, et `Cargo.toml` est intouché. Ce qui change, c'est le comportement de la couche TLS elle-même : outre ces quatre correctifs, la 0.23.46 apporte une dizaine d'autres corrections (conformité au protocole, durcissement, entrées-sorties), comme le refus des enregistrements `change_cipher_spec` protégés et la mise à jour des clés TLS 1.3 au moment voulu (#447).
|
||||
- Retrait de l'extraction d'archive de la mise à jour automatique (sa dépendance `zip` sous Windows et `tar` sous Linux), corrigeant RUSTSEC-2026-0367, RUSTSEC-2026-0368 et RUSTSEC-2026-0369. La mise à jour n'extrayait un paquet que s'il arrivait sous forme d'archive (un zip sous Windows, une AppImage en `.tar.gz` sous Linux), après vérification de sa signature, et les mises à jour de cette application ne sont jamais des archives : l'installeur Windows (`.exe`) est lancé tel quel et les paquets Linux (`.deb`, `.rpm`) sont installés par le gestionnaire de paquets du système. Aucun des trois avis, dont deux portent sur des permissions Unix alors que `zip` n'était compilé que pour Windows, n'était atteignable ; la dépendance est retirée plutôt que contournée par un raisonnement, faute de version corrigée compatible avec la mise à jour. Les mises à jour de l'installeur Windows et des paquets Linux fonctionnent comme avant (#455).
|
||||
|
||||
## [0.17.1] - 2026-10-08
|
||||
|
||||
### Ajouté
|
||||
|
||||
- Un bandeau de démarrage en haut de chaque page tant que le parcours de démarrage est affiché : « Démarrage 3/6 », l'étape suivante avec un lien vers la page où la faire, « Voir la liste », qui déplie les six étapes en place, et « Masquer », qui masque le bandeau et la liste pour le profil courant. Il n'apparaît pas sur le tableau de bord d'un profil sans transaction, dont la carte d'accueil contient déjà la liste. Une fois toutes les étapes faites ou passées, il l'annonce et propose « Ouvrir le guide », qui mène à la section « Premiers pas » du guide d'utilisation, et « Fermer », qui le retire jusqu'à un réaffichage depuis Paramètres → Utilisateurs (#397).
|
||||
- Des cartes de démarrage sur les pages Import, Transactions et Budget. Tant que l'étape suivante du parcours se fait sur l'une de ces pages, une carte y prend la place du bandeau : elle indique « Étape n sur N », dit quoi faire et désigne le contrôle à utiliser par un contour qui pulse trois fois, et pas du tout quand le système demande de réduire les animations. Quand l'étape est faite, la carte reste, annonce « Fait. » et propose « Étape suivante », qui mène à la page de l'étape d'après, ou la fin du parcours. Arriver sur une page par un lien « Étape suivante » place le focus du clavier sur le titre de la carte. « Passer cette étape » marque l'étape comme passée dans la liste de démarrage, où « Reprendre » l'annule (#398, #399, #400).
|
||||
- Sur la page Import, la carte de démarrage suit l'assistant d'import écran par écran, à travers ses trois étapes (choisir un dossier d'import, configurer une source d'import, importer un premier fichier), et annonce « Fait. » une fois le fichier importé, jamais pour un import qui n'a ajouté aucune transaction (#398).
|
||||
- Sur la page Transactions, le lien de l'étape « Classer une transaction à la main » ouvre la page sur les transactions non catégorisées, toutes dates confondues, ou sur toutes les transactions s'il n'en reste aucune à classer, et la carte désigne la catégorie de la première transaction affichée. En édition Gratuite, où l'étape Budget est verrouillée, classer une transaction termine le parcours (#399).
|
||||
- Sur la page Budget, dont la grille s'ouvre catégories repliées, la carte de démarrage désigne d'abord « Tout déplier », puis la cellule du mois courant de la première catégorie à remplir, et ne déplie jamais la grille à votre place. Elle rappelle qu'Entrée enregistre, et que Tab enregistre et passe au mois suivant. Enregistrer un montant termine le parcours (#400).
|
||||
- Une sous-section « Le parcours de démarrage » dans la section « Premiers pas » du guide d'utilisation, celle qu'ouvre « Ouvrir le guide » à la fin du parcours : le bandeau et sa liste, les cartes d'étape et leur contour, « Passer cette étape » et « Reprendre », la fin du parcours, les profils qui le trouvent masqué d'office, et son réaffichage depuis Paramètres → Utilisateurs. Les sections Tableau de bord, Profils et Paramètres du guide décrivent le parcours tel qu'il fonctionne désormais (#401).
|
||||
|
||||
### Modifié
|
||||
|
||||
- Le parcours de démarrage ne s'affiche plus dans un profil qui avait déjà des transactions avant cette version : à la première ouverture d'un tel profil, le bandeau et sa liste sont masqués, comme après un clic sur « Masquer », et « Afficher la liste de démarrage », dans Paramètres → Utilisateurs, les fait revenir. Un profil sans transaction les garde (#396).
|
||||
- Le tableau de bord n'affiche plus la barre compacte de démarrage une fois que le profil a des transactions : le bandeau la remplace sur chaque page. Dans la liste détaillée, les lecteurs d'écran annoncent désormais si chaque étape est faite, passée, verrouillée ou en attente (#397).
|
||||
- Paramètres → Utilisateurs propose « Afficher la liste de démarrage » dès que le parcours est masqué, y compris depuis le bandeau de cette même page, et confirme désormais que le parcours réapparaît en haut des pages (#397).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- La page Import n'affiche plus un bref instant l'état vide « Aucun dossier d'import » à son ouverture, pendant la lecture du dossier d'import enregistré (#398).
|
||||
- L'étape de démarrage « Importer un premier fichier » ne se coche plus après un import qui n'a ajouté aucune transaction, par exemple un fichier réduit à sa ligne d'en-tête, ou dont toutes les lignes étaient en erreur dans l'aperçu : l'étape attend désormais un fichier qui a amené au moins une transaction (#398).
|
||||
- Classer une transaction à la main sur la page Transactions coche désormais l'étape « Classer une transaction à la main » de la liste de démarrage. L'application enregistrait cette marque sous une forme que la liste ne savait pas compter. Les transactions classées à la main avant cette version sont comptées dès l'ouverture du profil, et une sauvegarde exportée entre-temps les restaure correctement (#390).
|
||||
- Les profils autres que le profil par défaut reçoivent désormais les mises à jour de leur base de données. Jusqu'ici, chacun gardait la structure de base de la version qui l'avait créé, et des fonctions ajoutées depuis pouvaient y échouer : dans un profil créé avant la 0.15.0, par exemple, le format d'import enregistré sur une source. À l'ouverture d'un tel profil, l'application garde d'abord une copie de sa base dans le dossier `migration-backups`, à côté des bases, puis applique les mises à jour manquantes. La copie est supprimée avec le profil, et chaque mise à jour est inscrite dans les journaux (Paramètres → Système) (#390).
|
||||
- Sous Linux, supprimer un profil supprime désormais sa base de données, qui restait sur le disque dans `~/.config/com.simpl.resultat/` ; le fichier d'un profil supprimé avec la version 0.17.0 ou antérieure peut être supprimé à la main. Les colonnes de rendement des comptes du Bilan affichent désormais les rendements sous Linux, où chaque cellule affichait un tiret (#390).
|
||||
|
||||
## [0.17.0] - 2026-10-05
|
||||
|
||||
### Ajouté
|
||||
|
||||
- États vides guidés sur le Tableau de bord, Import, Transactions et Budget : un profil sans transaction explique désormais ce que la page montrera et propose l'étape suivante (« Choisir un dossier d'import », « Importer des transactions ») au lieu d'un graphique ou d'un tableau vides (#352, #353, #356).
|
||||
- Une liste de démarrage sur le Tableau de bord, calculée depuis vos données : six étapes (créer un profil, choisir un dossier d'import, configurer une source d'import, importer un premier fichier, classer une transaction à la main, saisir un premier budget) se cochent d'elles-mêmes au fil de votre progression, et l'étape Budget est marquée « Base et plus » en édition Gratuite. La liste occupe le tableau de bord vide, se réduit à une barre compacte dès qu'il y a des transactions, disparaît quand toutes les étapes sont faites, et « Masquer » la retire pour le profil courant seulement (#351, #352).
|
||||
- « Créer un dossier d'exemple » sur la page Import quand le dossier d'import n'a aucun sous-dossier de banque : le bouton dépose deux sources fictives (« Exemple Desjardins », « Exemple Tangerine »), un relevé chacune couvrant les deux mois précédents, pour essayer l'assistant avant que vos vrais relevés soient en place. Il refuse d'écraser un dossier existant du même nom (#354, #356).
|
||||
- Une sous-section « Exporter un CSV depuis votre banque » dans le guide d'utilisation, avec une procédure par banque (Desjardins, Tangerine, RBC, Banque Nationale, BMO) et une ligne de statut indiquant si chacune a été confirmée sur un compte réel (#355).
|
||||
- « Retirer la clé » dans la carte Licence (Paramètres → Utilisateurs) retire la clé de licence enregistrée sur cet ordinateur, après une confirmation qui dit ce que cela change. Une clé valide : les modules de son édition se verrouillent et l'application repasse en édition Gratuite, vos données étant conservées. Une clé révoquée : l'application cesse de l'envoyer au serveur de licences, ce qu'elle faisait au plus une fois par 24 heures tant que la clé restait enregistrée. Quand cette machine est activée, sa place est d'abord libérée sur le serveur de licences ; si le serveur ne répond pas, l'application demande s'il faut retirer la clé quand même, et la place reste alors occupée jusqu'à ce que vous la libériez depuis un autre ordinateur où la clé est enregistrée. Une clé révoquée retirée puis entrée de nouveau reste révoquée. Chaque retrait est inscrit dans les journaux (Paramètres → Système) (#370).
|
||||
|
||||
### Modifié
|
||||
|
||||
- Paramètres → Utilisateurs propose « Afficher la liste de démarrage » une fois la liste masquée, et confirme qu'elle réapparaîtra sur le tableau de bord (#352).
|
||||
- Le popover d'aide « ? » de la page Import renvoie désormais à la sous-section « Exporter un CSV depuis votre banque » du guide, comme l'état vide d'Import (#356).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- Une clé de licence révoquée par le serveur de licences et saisie en cours de session fait désormais repasser l'application en édition Gratuite sans attendre : la clé est vérifiée dès son enregistrement, en arrière-plan, au lieu du démarrage suivant. Elle gardait auparavant son édition jusqu'au redémarrage de l'application, ou jusqu'à une tentative d'activation de la machine. La saisie d'une clé rend toujours la main tout de suite, en ligne comme hors ligne, et une clé révoquée affiche son édition une à deux secondes avant de basculer. La vérification part toujours au plus une fois par 24 heures pour une même clé, et chacune est inscrite dans les journaux (Paramètres → Système) (#373).
|
||||
- Une machine dont l'activation ne sert plus retrouve « Activer » dans la carte Licence (Paramètres → Utilisateurs), sans manipulation de fichier. C'était le cas d'un ordinateur où une version jusqu'à la 0.15.0 avait laissé l'activation d'une licence précédente à côté d'une autre clé : la carte affichait « Activée », les cours du marché étaient refusés parce que cette licence était révoquée, et « Activer » n'était jamais proposé. L'activation est désormais retirée quand le serveur des cours la refuse parce qu'elle appartient à une licence révoquée (le message du refus invite désormais à activer de nouveau la machine), et quand la liste des machines, une fois ouverte, ne compte pas cette machine, ce qui couvre aussi une machine désactivée depuis un autre ordinateur. La clé enregistrée et son édition ne changent pas, et chaque retrait est inscrit dans les journaux (Paramètres → Système) (#367).
|
||||
|
||||
## [0.16.0] - 2026-10-03
|
||||
|
||||
### Ajouté
|
||||
|
||||
- Achat de l'édition Base depuis chaque écran qui la requiert : « Obtenir Base » est désormais actif sur Budget, Ajustements, les rapports avancés et les profils supplémentaires, et ouvre la page produit dans la langue de l'application (#346).
|
||||
- En édition Gratuite, la carte Mises à jour propose « Obtenir Base », « J'ai déjà une clé » et « Télécharger la dernière version », et la page d'erreur « Obtenir Base » et « Télécharger la dernière version ». Le lien de téléchargement ouvre la page produit, qui sert les derniers installateurs ; aucune vérification de mise à jour n'a lieu (#346).
|
||||
- Vérification de la licence au démarrage : quand une clé de licence est enregistrée, l'application demande désormais au serveur de licences si elle a été révoquée, au plus une fois par 24 heures. C'est la première requête que l'application envoie d'elle-même. Elle porte le produit et la clé, rien d'autre ; rien ne part sans clé enregistrée ; si le serveur est injoignable, rien ne change ; et chaque vérification est inscrite dans les journaux (Paramètres → Système). Une clé révoquée reste vérifiée tant qu'elle est enregistrée, pour qu'une correction faite par le serveur de licences soit reprise au démarrage suivant (#365).
|
||||
|
||||
### Modifié
|
||||
|
||||
- La carte Compte Maximus n'affirme plus qu'un compte est requis pour Premium : le compte sert à retrouver vos licences sur lacompagniemaximus.com et à vous identifier dans vos rétroactions, et les éditions viennent de la clé de licence (#346).
|
||||
- L'écran verrouillé du Bilan (Premium) indique désormais que Premium n'est pas encore offert en ligne, au lieu d'« Achat en ligne bientôt disponible » (#346).
|
||||
- En édition Gratuite, la carte Mises à jour indique que les mises à jour automatiques sont incluses dans l'édition Base (#346).
|
||||
- Une clé de licence révoquée par le serveur de licences, par exemple après un remboursement, fait désormais repasser l'application en édition Gratuite, mises à jour automatiques comprises, et elle y reste d'une session à l'autre. La carte Licence, les écrans verrouillés et la carte Mises à jour indiquent que la clé a été révoquée. Vos données sont conservées, et une autre clé valide redonne son édition. Une clé révoquée gardait auparavant son édition (#347, #365).
|
||||
- La liste des machines indique combien sont activées sur le nombre permis par votre clé (#347).
|
||||
- Une clé de licence valide pour plus de 50 ans affiche « Sans échéance » au lieu d'une date (#347).
|
||||
- L'erreur d'authentification des cours du marché indique désormais d'activer cette machine dans Paramètres → Utilisateurs (#347).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- L'activation, la désactivation et la liste des machines passent désormais par les points d'accès `/v1` du serveur de licences et nomment le produit, au lieu de passer par une redirection à chaque appel (#270).
|
||||
- Un refus du serveur de licences est désormais expliqué dans votre langue : clé révoquée, expirée ou introuvable, nombre maximal de machines atteint, trop de tentatives (avec le délai à attendre), serveur injoignable. Il s'affichait auparavant en anglais sous un « Activation failed » générique, et une clé révoquée apparaissait comme « Aucune machine activée » (#270).
|
||||
- Quand le nombre maximal de machines est atteint, la liste des machines s'ouvre d'elle-même pour en désactiver une, et le message reste affiché pendant son chargement. Désactiver une machine ne réinterroge plus le serveur, et désactiver une machine que le serveur considère déjà libérée compte désormais comme réussi (#270).
|
||||
- Le bouton « Acheter » de la carte Licence ouvre désormais directement la page produit, dans la langue de l'application, au lieu d'une ancienne adresse qui ne fonctionne que grâce à une redirection du site (#270).
|
||||
- Une clé de licence valide garde désormais son édition quelle que soit l'activation de la machine : une activation expirée (au bout de deux ans), ou reprise d'un autre ordinateur quand vous restaurez vos données après une réinstallation ou un changement de poste, ne fait plus retomber l'application en édition Gratuite. La carte Licence indique que la machine n'est pas activée, et « Activer » rétablit les cours du marché (#347, #360).
|
||||
- Une clé de licence refusée est désormais expliquée dans votre langue (préfixe incorrect, clé illisible ou qui n'a pas pu être vérifiée, le plus souvent parce qu'elle a été copiée en partie, clé expirée, édition inconnue de cette version) au lieu d'un message anglais brut, et un fichier de licence illisible est nommé lui aussi (#347).
|
||||
- Les dates de la carte Licence suivent la langue de l'application plutôt que celle du système (#347).
|
||||
- Enregistrer une autre clé de licence efface désormais l'activation de la clé précédente : la carte Licence propose « Activer » pour la nouvelle clé. Elle affichait auparavant la machine comme activée alors que les cours du marché étaient refusés au nom de l'ancienne licence, par exemple après un rachat suivant un remboursement, ou lors du passage de Base à Premium. La saisie d'une clé et l'activation d'une machine ne peuvent plus se chevaucher, donc plus se croiser (#347).
|
||||
- Les cours du marché refusés parce que l'activation de cette machine est liée à une licence révoquée le disent désormais, au lieu de « Cette fonction nécessite un abonnement premium » (#365).
|
||||
|
||||
### Sécurité
|
||||
|
||||
- Mise à jour de la dépendance Rust `rustls` (0.23.36 → 0.23.45, qui exige `rustls-webpki` 0.103.13 → 0.103.15 avec elle), corrigeant RUSTSEC-2026-0285. Les messages de poignée de main TLS 1.3 étaient acceptés au mauvais niveau de chiffrement lorsqu'ils suivaient un message de changement de clé dans le même enregistrement — un `EncryptedExtensions` en clair empaqueté avec le `ServerHello`, par exemple — là où la RFC 8446 impose de fermer la connexion. Sévérité moyenne, confidentialité seulement : la transcription de la poignée de main reste authentifiée, donc cela ne permet ni d'altérer ni de compléter une poignée de main depuis une position réseau ; l'effet est qu'un pair peut envoyer en clair ce qui aurait dû être chiffré sans que rustls refuse la connexion. `rustls` est la couche TLS de la vérification et du téléchargement des mises à jour automatiques, atteinte via `tauri-plugin-updater` — les appels de licence, d'authentification, de cours et de rétroaction de l'application elle-même passent par un autre moteur TLS et n'ont jamais été sur ce chemin. Rien ne change dans le code de l'application elle-même — seul le fichier de verrouillage bouge, et `Cargo.toml` est intouché. Le seul comportement qui change est le correctif lui-même : une poignée de main qui enfreint la RFC 8446 §5.1 est désormais fermée au lieu d'être acceptée (#348).
|
||||
- Activer une machine n'envoie plus au serveur de licences l'identifiant machine du système d'exploitation. L'application envoie désormais un identifiant dérivé de celui-ci et propre à Simpl'Résultat, qui ne permet pas de retrouver l'identifiant du système. Une machine activée avec une version antérieure reste activée et toujours marquée « Cette machine » dans la liste ; cette activation antérieure garde l'identifiant avec lequel elle a été faite, jusqu'à ce que vous désactiviez la machine puis l'activiez de nouveau. Ce changement n'efface pas les identifiants que le serveur a déjà reçus (#350).
|
||||
|
||||
## [0.15.0] - 2026-09-12
|
||||
|
||||
### Ajouté
|
||||
|
|
|
|||
111
CHANGELOG.md
111
CHANGELOG.md
|
|
@ -2,6 +2,117 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.17.2] - 2026-10-18
|
||||
|
||||
### Added
|
||||
|
||||
- Import: a warning names the CSV files placed directly in the import folder, which are ignored (#427).
|
||||
- Getting started: two visit steps, “Browse categories and keywords” (before categorizing) and “Browse the reports” (last), checked with “Got it”; a profile that had finished the path finds them already done; their cards, on the Categories and Reports pages, explain keywords and the reports hub (#425, #432).
|
||||
|
||||
### Changed
|
||||
|
||||
- The "Parent category" field of the category form and the "Category" field of the "New keyword" dialog (right-click on a transaction) now take typing, like the category cell of the Transactions page: type a few letters to filter the list, which shows the hierarchy with indentation and the names of the standard categories in your language. The parent list no longer offers the category being edited, its own sub-categories, or a parent that would create a fourth level; a parent already stored stays shown. The keyword dialog offers the same categories as the Transactions page (active and allowed for input) and keeps the pre-filled category only when it is among them; its two fields sit above the scrolling list of matches, so the open category list is never cut (#423).
|
||||
- One copy is kept per profile in the `migration-backups` folder: the copy taken before a database update is now taken again at each update, and once the update succeeds, the older copies of that profile are deleted. A copy left by an update that stopped is no longer reused as is, since it predated what you entered after it. When an update stops, the Journal now names the copy taken before it (#395).
|
||||
- Import: the empty state shows the expected structure (a parent folder, one subfolder per account), and the getting-started cards ask you to choose the parent folder and to check that an expense counts among the outflows (#422).
|
||||
- Getting started: the Transactions card explains keywords and suggests “Add as keyword”; the Budget card states that only last-level categories take an amount and what the Annual column does (#430).
|
||||
- Splitting a transaction across categories (the Split button of the Transactions page) is now part of the Base edition, as the unlock screen already promised. On the Free edition the button keeps its place, shows a lock and opens the unlock screen instead of the split window, for every row. Splits made earlier stay in your data: they are still counted in the reports and the dashboard, and still flagged in the list (#369).
|
||||
|
||||
### Fixed
|
||||
|
||||
- The startup error page no longer stays on screen when the profile finishes opening after the 10-second delay: the app now shows as soon as the profile is open (#395).
|
||||
- In French, the small badge next to the "Fetch price" button of a stock in the Balance sheet now reads "non garanti" instead of the English "best-effort" (#389).
|
||||
- Installs from the `.rpm` package now receive automatic updates. The update manifest only listed the `.deb`, which an `.rpm` install downloaded and then refused; it now lists each Linux package under its own key. Existing `.rpm` installs need no reinstall: they will receive the next version (checked on the published v0.17.1 package). The download instructions of the release page no longer mention an AppImage, which is not built, and now name the `.rpm`. The manifest no longer has a generic Linux entry: the AppImage builds of 0.4.3 to 0.4.5, which could not install the `.deb` it pointed to and were broken by it, now report an error when checking for updates instead; install the `.deb` or `.rpm` package (#320).
|
||||
- Budget: an expanded subcategory now shows under its parent category instead of at the end of the group. With subtotals at the bottom, each subtotal follows its own rows, in the Budget grid as in the Budget vs Actual and Compare reports (#424).
|
||||
- Import: a source is named after its subfolder, read-only; a source renamed during configuration is no longer taken for a new source at the next import (#426).
|
||||
- Help and guide: Budget describes typing in the Annual column (no more split button) and that only last-level categories take an amount; Reports have five cards and no tabs; adding a keyword from a transaction goes through the right-click menu (#431).
|
||||
- Deleting a profile, a category, an adjustment, a budget template or a Balance sheet category, and removing the license key, now wait for your answer to the confirmation. Until now the action ran as soon as the confirmation appeared, and “Cancel” stopped nothing: a deleted profile lost its data. The confirmation buttons now follow the app language (#421).
|
||||
|
||||
### Security
|
||||
|
||||
- Updated npm dependencies to clear the eight vulnerabilities reported by `npm audit` (five high, three moderate), through the lockfile alone: every new version stays within the range `package.json` already declares. `react-router-dom` and `react-router` 7.18.1 → 7.18.4 clear GHSA-qwww-vcr4-c8h2 (CSRF in React Server Components mode), the advisory left as is until now because leaving its range seemed to require migrating to react-router v8; 7.18.2 fixed it. The rest is build and test tooling, never shipped in the app: `vitest` and `@vitest/mocker` 4.1.9 → 4.1.11, `browserslist` 4.28.4 → 4.29.3, `baseline-browser-mapping` 2.10.41 → 2.11.28, `nanoid` 3.3.16 → 3.3.20 and `source-map-js` 1.2.1 → 1.2.2. None of these advisories was reachable in the shipped app, which runs react-router in its own window, with no server. Of all these updates, only react-router's changes the built app: it now refuses a navigation to another origin, which the app never makes, so navigation behaves as before (#358).
|
||||
- Updated the Rust dependency `rustls` (0.23.45 → 0.23.46), clearing RUSTSEC-2026-0346, RUSTSEC-2026-0347, RUSTSEC-2026-0348 and RUSTSEC-2026-0349. `rustls` is the TLS layer of the automatic update check and download, reached through `tauri-plugin-updater`; the application's own licence, authentication, market-price and feedback calls use a different TLS backend. None of the four applies to how the application uses it: no custom session store exists that could log session secrets (0346), Encrypted Client Hello is never enabled (0347), the optional Brotli certificate decompression is not compiled in (0348), and the `ring` cryptography provider offers no post-quantum key exchange that could be downgraded (0349). Updated anyway rather than reasoned around, since a fix exists. Nothing in the application's own code changes — only the lockfile moves, and `Cargo.toml` is untouched. What changes is the TLS layer's own behaviour: besides these four fixes, 0.23.46 carries about ten other corrections (protocol conformance, hardening, I/O), such as rejecting protected `change_cipher_spec` records and performing TLS 1.3 key updates on time (#447).
|
||||
- Removed the archive extraction of the automatic updater (its `zip` dependency on Windows and `tar` on Linux), clearing RUSTSEC-2026-0367, RUSTSEC-2026-0368 and RUSTSEC-2026-0369. The updater only extracted an update that arrived as an archive (a zip on Windows, a `.tar.gz` AppImage on Linux), after checking its signature, and this application's updates are never archives: the Windows installer (`.exe`) is run as it is and the Linux packages (`.deb`, `.rpm`) are installed by the system's package manager. None of the three advisories, two of which concern Unix permissions while `zip` was only compiled for Windows, was reachable; the dependency is removed rather than reasoned around, since no fixed version fits the updater. Updates of the Windows installer and of the Linux packages work as before (#455).
|
||||
|
||||
## [0.17.1] - 2026-10-08
|
||||
|
||||
### Added
|
||||
|
||||
- A getting-started banner at the top of every page while the getting-started path is shown: "Getting started 3/6", the next step with a link to the page where it is done, "Show the list", which unfolds the six steps in place, and "Hide", which hides the banner and the list for the current profile. It does not show on the dashboard of a profile without transactions, whose welcome card already holds the list. Once every step is done or skipped, it says so and offers "Open the guide", which leads to the "Getting Started" section of the user guide, and "Close", which removes it until it is shown again from Settings → Users (#397).
|
||||
- Getting-started cards on the Import, Transactions and Budget pages. While the next step of the path is done on one of these pages, a card takes the place of the banner there: it shows "Step n of N", says what to do and points at the control to use with an outline that pulses three times, not at all when the system asks for reduced motion. When the step gets done, the card stays, says "Done." and offers "Next step", which leads to the page of the following step, or the end of the path. Arriving on a page by a "Next step" link puts the keyboard focus on the card's title. "Skip this step" marks the step as skipped in the getting-started list, where "Resume" takes it back (#398, #399, #400).
|
||||
- On the Import page, the getting-started card follows the import wizard screen by screen through its three steps (choose an import folder, set up an import source, import a first file) and says "Done." once the file is imported, never for an import that added no transaction (#398).
|
||||
- On the Transactions page, the link of the "Categorize a transaction by hand" step opens the page on the uncategorized transactions whatever their date, or on every transaction when none is left uncategorized, and the card points at the category of the first transaction shown. In the Free edition, where the Budget step is locked, categorizing a transaction ends the path (#399).
|
||||
- On the Budget page, whose grid opens with its categories folded, the getting-started card first points at "Expand all", then at the current month's cell of the first category you can fill in, and never unfolds the grid for you. It reminds you that Enter saves, and that Tab saves and moves to the next month. Saving an amount ends the path (#400).
|
||||
- A sub-section, "The getting-started path", in the "Getting Started" section of the user guide, the one "Open the guide" opens at the end of the path: the banner and its list, the step cards and their outline, "Skip this step" and "Resume", the end of the path, the profiles that find it hidden from the start, and how to show it again from Settings → Users. The Dashboard, Profiles and Settings sections of the guide describe the path as it now works (#401).
|
||||
|
||||
### Changed
|
||||
|
||||
- The getting-started path no longer shows in a profile that already had transactions before this version: the first time such a profile is opened, the banner and its checklist are hidden, as if you had clicked "Hide", and "Show the getting-started checklist" in Settings → Users brings them back. A profile without transactions keeps them (#396).
|
||||
- The dashboard no longer shows the compact getting-started bar once the profile has transactions: the banner replaces it on every page. In the detailed list, screen readers now hear whether each step is done, skipped, locked or pending (#397).
|
||||
- Settings → Users offers "Show the getting-started checklist" as soon as the path is hidden, also when it was hidden from the banner of that same page, and now confirms that the path reappears at the top of the pages (#397).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Opening the Import page no longer flashes the "No import folder" empty state while the saved import folder is being read (#398).
|
||||
- The "Import a first file" getting-started step is no longer checked by an import that added no transaction, such as a file holding only its header line, or one whose rows were all in error in the preview: the step now waits for a file that brought at least one transaction (#398).
|
||||
- Categorizing a transaction by hand on the Transactions page now ticks the "Categorize a transaction by hand" step of the getting-started checklist. The app stored that mark in a form the checklist could not count. Transactions you categorized by hand before this version are counted as soon as the profile is opened, and a backup exported in the meantime restores them correctly (#390).
|
||||
- Profiles other than the default one now receive database updates. Until now each one kept the database layout of the version that created it, so features added since could fail in it: in a profile created before 0.15.0, for instance, the import format saved on a source. When such a profile is opened, the app first keeps a copy of its database in the `migration-backups` folder next to the databases, then applies the missing updates. The copy is deleted with the profile, and each update is written to the logs (Settings → System) (#390).
|
||||
- On Linux, deleting a profile now deletes its database, which used to stay on disk in `~/.config/com.simpl.resultat/`; the file of a profile deleted with version 0.17.0 or earlier can be deleted by hand. The return columns of the Balance sheet accounts now show the returns on Linux, where every cell showed a dash (#390).
|
||||
|
||||
## [0.17.0] - 2026-10-05
|
||||
|
||||
### Added
|
||||
|
||||
- Guided empty states on the Dashboard, Import, Transactions and Budget pages: a profile without transactions now explains what the page will show and offers the next step ("Choose an import folder", "Import transactions") instead of an empty chart or table (#352, #353, #356).
|
||||
- A getting-started checklist on the Dashboard, computed from your data: six steps (create a profile, choose an import folder, set up an import source, import a first file, categorize a transaction by hand, enter a first budget) tick themselves as you go, and the Budget step is marked "Base and up" in the Free edition. The list fills the empty dashboard, shrinks to a compact bar once transactions exist, disappears when every step is done, and "Hide" dismisses it for the current profile only (#351, #352).
|
||||
- "Create a sample folder" on the Import page when the import folder has no bank sub-folder: it writes two fictitious sources ("Exemple Desjardins", "Exemple Tangerine"), one statement each covering the two previous months, so you can try the wizard before your real statements are in place. It refuses to overwrite an existing folder of the same name (#354, #356).
|
||||
- An "Export a CSV from your bank" sub-section in the user guide, with a procedure per bank (Desjardins, Tangerine, RBC, National Bank, BMO) and a status line saying whether each one was confirmed on a real account (#355).
|
||||
- "Remove the key" on the License card (Settings → Users) removes the stored license key from this computer, after a confirmation that says what it changes. A valid key: the modules of its edition lock and the app goes back to the Free edition, with your data kept. A revoked key: the app stops sending it to the license server, which it did at most once every 24 hours for as long as the key stayed stored. When this machine is activated, its slot is freed on the license server first; if the server cannot be reached, the app asks whether to remove the key anyway, and the slot then stays occupied until you free it from another computer that has the key. A revoked key removed then entered again stays revoked. Each removal is written to the logs (Settings → System) (#370).
|
||||
|
||||
### Changed
|
||||
|
||||
- Settings → Users offers "Show the getting-started checklist" once the checklist was hidden, and confirms that it will reappear on the dashboard (#352).
|
||||
- The "?" help popover of the Import page now links to the guide's "Export a CSV from your bank" sub-section, as does the Import empty state (#356).
|
||||
|
||||
### Fixed
|
||||
|
||||
- A license key revoked by the license server and entered during a session now puts the app back in the Free edition right away: the key is checked as soon as it is stored, in the background, instead of at the next start. It used to keep its edition until the app restarted, or until you tried to activate the machine. Entering a key still answers at once, online or offline, and a revoked key shows its edition for a second or two before the switch. The check still goes out at most once every 24 hours for the same key, and each check is written to the logs (Settings → System) (#373).
|
||||
- A machine whose activation no longer serves gets "Activate" back on the License card (Settings → Users), without touching any file. This happened to a computer where a version up to 0.15.0 had left the activation of a previous license next to another key: the card read "Activated", market prices were refused because that license was revoked, and "Activate" was never offered. The activation is now removed when the price server refuses it as belonging to a revoked license (the refusal message now invites you to activate the machine again), and when the machine list, once opened, does not count this machine, which also covers a machine deactivated from another computer. The stored key and its edition are untouched, and each removal is written to the logs (Settings → System) (#367).
|
||||
|
||||
## [0.16.0] - 2026-10-03
|
||||
|
||||
### Added
|
||||
|
||||
- Buy the Base edition from every screen that requires it: "Get Base" is now active on Budget, Adjustments, the advanced reports and extra profiles, and opens the product page in the app's language (#346).
|
||||
- In the free edition, the Updates card offers "Get Base", "I already have a key" and "Download the latest version", and the error page offers "Get Base" and "Download the latest version". The download link opens the product page, which serves the latest installers; no update check runs (#346).
|
||||
- License check at startup: when a license key is stored, the app now asks the license server whether the key was revoked, at most once every 24 hours. This is the first request the app sends on its own. It carries the product and the key, nothing else; nothing is sent without a stored key; if the server cannot be reached, nothing changes; and each check is written to the logs (Settings → System). A revoked key keeps being checked as long as it stays stored, so that a correction made on the license server is picked up at the next start (#365).
|
||||
|
||||
### Changed
|
||||
|
||||
- The Maximus Account card no longer says an account is required for Premium: the account lets you find your licenses on lacompagniemaximus.com and identifies you in your feedback, and editions come from the license key (#346).
|
||||
- The Balance locked screen (Premium) now says Premium isn't sold online yet, instead of "Online purchase coming soon" (#346).
|
||||
- In the free edition, the Updates card says automatic updates are included in the Base edition (#346).
|
||||
- A license key revoked by the license server, for instance after a refund, now puts the app back in the Free edition, automatic updates included, and the app stays there from one session to the next. The License card, the locked screens and the Updates card say that the key was revoked. Your data is kept, and another valid key gives its edition back. A revoked key used to keep its edition (#347, #365).
|
||||
- The machine list shows how many machines are activated out of the number your key allows (#347).
|
||||
- A license key valid for more than 50 years shows "No expiry date" instead of a date (#347).
|
||||
- The market price authentication error now tells you to activate this machine in Settings → Users (#347).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Machine activation, deactivation and the machine list now call the license server's current `/v1` endpoints and name the product explicitly, instead of going through a redirect on every call (#270).
|
||||
- A refusal from the license server is now explained in your language: revoked, expired or unknown key, machine limit reached, too many attempts (with the wait time), server unreachable. It used to show a generic "Activation failed" in English, and a revoked key showed up as "No machines activated" (#270).
|
||||
- When the machine limit is reached, the machine list opens by itself so you can deactivate one, and the message stays visible while it loads. Deactivating a machine no longer queries the server a second time, and deactivating a machine the server already considers free now counts as done (#270).
|
||||
- The "Buy" button of the License card now opens the product page directly, in the app's language, instead of an old address that only works through a redirect on the website (#270).
|
||||
- A valid license key now keeps its edition whatever the machine activation: an activation that has expired (after two years), or one carried over from another computer when you restore your data after a reinstall or a move, no longer drops the app to the Free edition. The License card shows the machine as not activated, and "Activate" brings back the market prices (#347, #360).
|
||||
- A refused license key is now explained in your language (wrong prefix, a key that cannot be read or verified, most often because it was not copied in full, an expired key, an edition this version does not know) instead of a raw English message, and a license file that cannot be read is named too (#347).
|
||||
- Dates on the License card follow the app's language instead of the system's (#347).
|
||||
- Entering a different license key now clears the previous key's machine activation, so the License card offers "Activate" for the new key. It used to show the machine as activated while market prices were refused for the previous license, for instance after a new purchase following a refund, or when moving from Base to Premium. Entering a key and activating a machine can no longer overlap, so they cannot cross either (#347).
|
||||
- Market prices refused because this machine's activation belongs to a revoked license now say so, instead of "This feature requires a premium subscription" (#365).
|
||||
|
||||
### Security
|
||||
|
||||
- Updated the Rust dependency `rustls` (0.23.36 → 0.23.45, which requires `rustls-webpki` 0.103.13 → 0.103.15 alongside it), clearing RUSTSEC-2026-0285. TLS 1.3 handshake messages were accepted at the wrong encryption level when they followed a key-changing message inside the same record — a plaintext `EncryptedExtensions` packed together with the `ServerHello`, for instance — where RFC 8446 requires closing the connection instead. Medium severity, confidentiality only: the handshake transcript stays authenticated, so this cannot be used to alter or complete a handshake from a network position; the effect is that a peer can send in the clear what should have been encrypted without rustls refusing the connection. `rustls` is the TLS layer of the automatic update check and download, reached through `tauri-plugin-updater` — the application's own licence, authentication, market-price and feedback calls use a different TLS backend and were never on this path. Nothing in the application's own code changes — only the lockfile moves, and `Cargo.toml` is untouched. The one behaviour that does change is the fix itself: a handshake that breaks RFC 8446 §5.1 is now closed instead of accepted (#348).
|
||||
- Activating a machine no longer sends the operating system's machine identifier to the license server. The app now sends an identifier derived from it and specific to Simpl'Résultat, from which the system identifier cannot be recovered. A machine activated with an earlier version stays activated and is still marked "This machine" in the list; that earlier activation keeps the identifier it was made with until you deactivate the machine and activate it again. This change does not erase the identifiers the server has already received (#350).
|
||||
|
||||
## [0.15.0] - 2026-09-12
|
||||
|
||||
### Added
|
||||
|
|
|
|||
17
CLAUDE.md
17
CLAUDE.md
|
|
@ -11,7 +11,7 @@
|
|||
**Stockage :** SQLite local (tauri-plugin-sql)
|
||||
**Langues supportées :** Français (FR) et Anglais (EN)
|
||||
**Plateformes :** Windows, Linux
|
||||
**Version actuelle :** 0.15.0
|
||||
**Version actuelle :** 0.17.2
|
||||
**Licence :** GPL-3.0-only
|
||||
|
||||
---
|
||||
|
|
@ -65,11 +65,12 @@ src-tauri/
|
|||
│ ├── commands/ # 3 modules, 17 commandes Tauri
|
||||
│ │ ├── fs_commands.rs # Système de fichiers (6 commandes)
|
||||
│ │ ├── export_import_commands.rs # Export/import chiffré (5 commandes)
|
||||
│ │ └── profile_commands.rs # Gestion des profils (6 commandes)
|
||||
│ │ └── profile_commands.rs # Gestion des profils (8 commandes)
|
||||
│ ├── database/ # Schémas SQL et migrations
|
||||
│ │ ├── schema.sql # Schéma initial (v1)
|
||||
│ │ ├── seed_categories.sql # Seed catégories (v2)
|
||||
│ │ └── consolidated_schema.sql # Schéma complet (nouveaux profils)
|
||||
│ │ ├── consolidated_schema.sql # Schéma complet (nouveaux profils)
|
||||
│ │ └── profile_db.rs # Rattrapage des profils secondaires (ADR 0021)
|
||||
│ ├── lib.rs # Point d'entrée, 17 migrations inline, plugins
|
||||
│ └── main.rs
|
||||
└── Cargo.toml
|
||||
|
|
@ -120,7 +121,7 @@ src-tauri/
|
|||
|
||||
- **20 tables** SQLite, **24 index** (voir `docs/architecture.md` pour le détail). Le module Bilan en représente 7 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`, puis `balance_securities` + `balance_snapshot_holdings` ajoutées en Étape 2 — détail par titre) et 9 index. Ces deux comptes sont inchangés depuis v13 : v14-v16 ajoutent 2 tables + 2 index (déjà comptés), v17 est un `ALTER TABLE` pur
|
||||
- **17 migrations inline** dans `lib.rs` (v1→v17, via `tauri_plugin_sql::Migration`). Étape 2 du Bilan (détail par titre) : v14 (`balance_securities` + `balance_snapshot_holdings` + 2 index), v15 (`balance_accounts.kind` + `detailed_since` + backfill), v16 (conversion des comptes cotés existants en détaillés 1-position) — voir [ADR 0015](docs/adr/0015-balance-detail-par-titre.md). Import : v17 (`import_sources.amount_mode` + `sign_convention`, les deux avec `CHECK`, + `header_signature` + `template_id`) — voir [ADR 0019](docs/adr/0019-format-import-persiste.md)
|
||||
- **Schéma consolidé** (`consolidated_schema.sql`) pour l'initialisation des nouveaux profils
|
||||
- **Schéma consolidé** (`consolidated_schema.sql`) pour l'initialisation des nouveaux profils, suivi de l'historique complet des migrations. tauri-plugin-sql ne migre que le profil par défaut : les profils secondaires rattrapent leurs migrations à chaque ouverture (`prepare_profile_db`, copie préalable, clés étrangères actives) — voir [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md). Les bases vivent sous `app_config_dir()` (`profile_db_path`), qui n'est pas `app_data_dir()` sous Linux
|
||||
- Les migrations appliquées sont protégées par checksum — ne jamais modifier une migration existante, toujours en créer une nouvelle
|
||||
|
||||
---
|
||||
|
|
@ -133,6 +134,8 @@ La documentation technique est centralisée dans `docs/` :
|
|||
- `docs/guide-utilisateur.md` — Guide utilisateur
|
||||
- `docs/archive/` — Anciennes spécifications archivées
|
||||
|
||||
Les règles de revue des PR (niveaux de risque, ce qui compte par zone, commandes de vérification, pièges) sont dans [`REVIEW.md`](REVIEW.md), à la racine ; un changement qui rend faux un fait qu'il cite le met à jour dans la même PR.
|
||||
|
||||
**Règle : quand un changement touche l'architecture, mettre à jour la documentation :**
|
||||
- Nouveau service, hook, commande Tauri, page/route, ou table SQL → mettre à jour `docs/architecture.md`
|
||||
- Décision technique structurante (choix de librairie, pattern architectural, changement de stratégie) → créer un nouvel ADR dans `docs/adr/`
|
||||
|
|
@ -161,12 +164,12 @@ Pour maintenir l'éligibilité aux crédits d'impôt R&D (RS&DE fédéral + CRIC
|
|||
|
||||
Workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** — les jobs se suivent, ils ne tournent pas en parallèle.
|
||||
|
||||
- **`check-rust.yml`** — déclenché sur les PR touchant `src-tauri/**` ou `.cargo/**`. Lance `cargo check`, une vérification bloquante que les advisories acceptées restent non atteignables, `cargo test` et un `cargo audit` informatif. Doit être vert avant tout merge.
|
||||
- **`check-frontend.yml`** — déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc ou du markdown. Lance `npm run build` (tsc + vite) et `npm test` (vitest). Doit être vert avant tout merge. **Aucune étape `npm audit`** : les advisories npm ne sont pas un gate de CI. `npm audit` remonte 2 high en permanence — une seule advisory `react-router` (mode RSC, inatteignable dans une app de bureau sans serveur), acceptée et suivie en [#317](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/317).
|
||||
- **`check-rust.yml`** — déclenché sur les PR touchant `src-tauri/**` ou `.cargo/**`, plus chaque lundi à 06:30 UTC sur `main` et par `workflow_dispatch`. Lance `cargo check`, une vérification bloquante que les advisories acceptées restent non atteignables, une autre que les sept arêtes `windows-sys` à borne large du lock restent sur `0.61.2` ([ADR 0023](docs/adr/0023-windows-sys-pinning.md)), `cargo test` et un `cargo audit` informatif, avec le cache `Swatinem/rust-cache`. Doit être vert avant tout merge. Un `cargo update` qui re-résout ces arêtes (`-p hyper` et `-p h2` l'ont fait) fait rougir le garde `windows-sys` : remettre les arêtes à la main selon l'ADR, ne jamais modifier la liste gardée pour le faire passer.
|
||||
- **`check-frontend.yml`** — déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc, du markdown, `check-rust.yml` ou `audit.yml` ; `.claude/**` et `release.yml` le déclenchent, car `scripts/build-latest-json.test.mjs` lit `verify-release.sh` et exécute l'étape « Publish » de `release.yml` (#419). Lance `npm run build` (tsc + vite) et `npm test` (vitest). Doit être vert avant tout merge. **Aucune étape `npm audit`** : les advisories npm ne sont pas un gate de CI. `npm audit` à 0 au 2026-10-10 ([#358](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/358)), un constat daté : aucun job ne le fait échouer, mais `npm ci` en imprime le compte à chaque run de `check-frontend`, et le Défenseur relève les advisories de son côté (#358 en vient).
|
||||
- **`audit.yml`** — audit RustSec quotidien (06:00 UTC) + `workflow_dispatch`, pour couvrir les avis de sécurité entre deux PR Rust. Échec bloquant. **Un run vert signifie « zéro advisory hors de la liste acceptée »**, pas « zéro advisory » : cette liste est dans `.cargo/audit.toml` (preuve de non-atteignabilité et condition de retrait par entrée), encadrée par l'[ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md). Ne jamais y ajouter une advisory atteignable ni élargir une entrée à un crate entier.
|
||||
- **`release.yml`** — déclenché par les tags `v*`. Build Windows (NSIS `.exe`) + Linux (`.deb`, `.rpm`), signe les binaires et publie le JSON d'updater pour les mises à jour automatiques.
|
||||
|
||||
Aucun workflow `check-*` ne filtre sur `branches:` : une PR stackée sur une autre branche de feature déclenche donc bien la CI. Le cache Actions est retiré partout tant que [#234](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/234) (connectivité du serveur de cache) n'est pas réglé — restore et save échouent tous les deux. Le miroir `.github/workflows/` est dormant (aucune PR côté GitHub).
|
||||
Aucun workflow `check-*` ne filtre sur `branches:` : une PR stackée sur une autre branche de feature déclenche donc bien la CI. Le cache Rust passe par `Swatinem/rust-cache` ([#234](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/234)) : le runner garde ce qu'écrit un run de PR pour cette seule PR, donc c'est le run du lundi (ou un `workflow_dispatch`) qui alimente le cache partagé que lisent les autres PR. Un run à froid reste normal après une nouvelle version stable de Rust ou une modification de l'`env:` du job, quand une entrée expire (7 jours sans usage, 30 jours d'âge), ou quand une PR sauvegarde sous la clé de l'entrée partagée et l'évince, le runner ne gardant qu'une entrée par clé. La liste complète est en tête de `check-rust.yml` ; seul un délai dépassé signale un retour de #234. Pas de cache npm : `check-frontend` ne tourne que sur des PR, et `npm ci` y prend 9 s. Le miroir `.github/workflows/` est dormant (aucune PR côté GitHub).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -38,11 +38,10 @@ Application de bureau 100 % locale pour importer, catégoriser et analyser vos t
|
|||
### Linux
|
||||
|
||||
1. Rendez-vous sur la page [**Releases**](https://github.com/Le-King-Fu/simpl-resultat/releases/latest)
|
||||
2. Téléchargez le format adapté à votre distribution :
|
||||
2. Téléchargez le paquet adapté à votre distribution :
|
||||
- `.deb` pour Debian / Ubuntu
|
||||
- `.rpm` pour Fedora / openSUSE
|
||||
- `.AppImage` pour toute distribution (exécutable universel)
|
||||
3. Installez le paquet ou lancez l'AppImage directement
|
||||
3. Installez le paquet
|
||||
|
||||
## Démarrage rapide
|
||||
|
||||
|
|
|
|||
190
REVIEW.md
Normal file
190
REVIEW.md
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
# REVIEW.md — Simpl'Résultat
|
||||
|
||||
Règles de revue des PR de ce dépôt : niveau de risque, ce qui compte dans chaque zone, commandes de vérification et pièges connus. Ce fichier complète `CLAUDE.md` et `.claude/rules/` sans les remplacer : une zone en reprend ce qui sert à la revue, avec sa source.
|
||||
|
||||
À partir de #457, `/pr-review` lit ce fichier sur `main`, y compris pour une PR empilée : une PR qui le modifie est jugée par la version de `main`, et ses nouvelles règles valent pour les PR suivantes.
|
||||
|
||||
## Niveaux
|
||||
|
||||
Le niveau fixe la profondeur de la revue, que `/pr-review` règle pour chaque niveau à partir de #457. Il se calcule sur les fichiers que touche le diff, jamais sur l'auteur (Max, `/fix-issue`, `/autopilot`) :
|
||||
|
||||
- un fichier désigné par une ligne du tableau **Haut** dont la condition est remplie est de niveau haut ; sinon, un fichier désigné par **Bas** est de niveau bas ; sinon il est normal ;
|
||||
- la PR prend le niveau le plus haut de ses fichiers, et la revue l'annonce avec le fichier qui le fixe ;
|
||||
- un fichier renommé, déplacé ou supprimé se juge sur son ancien et sur son nouveau chemin : un fichier de niveau haut déplacé reste haut, et la PR qui le déplace met ce tableau à jour ;
|
||||
- une condition qu'on ne sait pas trancher à la lecture du diff compte comme remplie.
|
||||
|
||||
Motifs : chemins depuis la racine. `*` ne traverse jamais un `/` ; `**/` vaut zéro, un ou plusieurs dossiers (`**/*.md` couvre `CLAUDE.md`, à la racine) ; `{a,b}` liste des variantes.
|
||||
|
||||
### Haut
|
||||
|
||||
| Motif | Condition | Zone |
|
||||
|---|---|---|
|
||||
| `src-tauri/src/database/**` | toujours | Migrations, profils et données |
|
||||
| `src-tauri/src/lib.rs` | sauf si le diff se limite à des entrées de `generate_handler![…]` ou au module `tests` | Migrations (`migrations()`), plugins, deep link OAuth |
|
||||
| `src-tauri/src/commands/profile_commands.rs` | toujours | Migrations, profils et données |
|
||||
| `src-tauri/src/commands/{license_commands,entitlements,auth_commands,token_store,account_cache}.rs`, `src/services/{licenseService,authService}.ts`, `.claude/skills/issue-license/**` | toujours | Jetons, licence et édition |
|
||||
| `src-tauri/src/commands/balance_commands.rs` | le diff touche `fetch_price` ou la lecture du jeton d'activation | Jetons, licence et édition |
|
||||
| `src-tauri/src/commands/export_import_commands.rs` | toujours | Jetons, licence et édition (export chiffré) |
|
||||
| `src-tauri/src/commands/feedback_commands.rs`, `src/services/feedbackService.ts` | toujours | Vie privée |
|
||||
| code livré : `src/**`, `src-tauri/src/**`, `index.html`, `public/**` | le diff ajoute un appel réseau, change ce qu'un appel existant envoie, ouvre une URL qui porte une donnée de l'utilisateur (donnée financière, identifiant, contenu d'un profil, ligne du Journal), ou retire ou contourne un consentement (celui des cours vit côté front : `src/components/balance/PriceFetchControl.tsx`, `src/components/settings/PriceFetchConsentToggle.tsx`) | Vie privée |
|
||||
| `src-tauri/capabilities/**` | toujours | Vie privée |
|
||||
| `src-tauri/{tauri*.conf.json*,Tauri*.toml}` | sauf si le diff se limite au champ `"version"` ; un fichier propre à une plateforme est fusionné avec `tauri.conf.json` | Vie privée (CSP), Release (updater, `beforeBuildCommand`) |
|
||||
| `.forgejo/workflows/release.yml`, `scripts/build-latest-json*`, `scripts/check-latest-json.jq`, `.claude/skills/release/**` | toujours | Release et mise à jour |
|
||||
| `src-tauri/build.rs`, `{vite,vitest,postcss}.config.*`, `.postcssrc*`, `.npmrc`, `**/rust-toolchain*`, `Cargo.{toml,lock}` à la racine | toujours | Release et mise à jour, CI et gardes (lu ou exécuté par le build de la release ou par `npm test`) |
|
||||
| fichier à la racine du dépôt ou directement dans `src-tauri/`, hors `*.md` | ajouté, renommé ou supprimé, comme tout fichier d'un dossier caché nouveau : une configuration nouvelle peut changer le build ou la CI sans motif qui la nomme | Release et mise à jour, CI et gardes |
|
||||
| `src-tauri/Cargo.toml`, `package.json` | sauf si le diff se limite au champ `version` de l'app (dépendances, features dont `dev-override`, `overrides`, scripts) | Dépendances ; Release pour les `scripts` de `package.json` |
|
||||
| `src-tauri/Cargo.lock`, `package-lock.json` | le diff ajoute ou retire un paquet (un bloc `[[package]]` dont la ligne `name =` apparaît ou disparaît, une seconde version d'un crate comprise ; une clé `"node_modules/<nom>"` qui apparaît ou disparaît), ou change l'origine d'un paquet : hôte ou schéma de `source` (Cargo) ou de `resolved` (npm), avec ou sans nouvelle version, ou `checksum`, `integrity` changés sans nouvelle version | Dépendances |
|
||||
| `**/.cargo/**` | toujours | Dépendances |
|
||||
| `.forgejo/workflows/**` | fichier ajouté, ou diff qui ajoute une référence à `secrets` ou un déclencheur `pull_request_target`, qui exécute la définition de la base avec ses secrets pour toute PR, forks compris | CI et gardes |
|
||||
| `.forgejo/workflows/audit.yml` | le diff change une ligne active : sans déclencheur `pull_request`, il tourne pour la première fois sur `main` | CI et gardes |
|
||||
| `.forgejo/workflows/{check-rust,check-frontend}.yml` | le diff peut faire passer au vert ce qui échouait, par exemple : déclencheur retiré ou restreint (`paths`, `paths-ignore`, `branches`, `schedule`) ; étape de verdict modifiée ou retirée (`npm ci`, `npm run build`, `npm test`, `cargo check`, `cargo test`, `Verify …`, `cargo audit`) ; `continue-on-error` ou `\|\| true` ajouté ; étape `run:` ajoutée qui écrit `$GITHUB_ENV`, `$GITHUB_PATH` ou un fichier du dépôt ; action `uses:` ajoutée, hors cache ; étape d'installation modifiée (toolchain, binaire placé dans le `PATH`) ; `if:`, `defaults:`, `permissions:`, `env:` ou `container:` modifiés | CI et gardes |
|
||||
| `.claude/settings.json`, `.mcp.json` | toujours : hooks, permissions et serveurs MCP décident de ce que les agents exécutent sur le poste, sans CI pour le voir | CI et gardes |
|
||||
| `REVIEW.md` | le diff touche la section « Niveaux » | Règles de revue |
|
||||
|
||||
Restent au moins normaux, et leurs règles de zone s'appliquent quand même :
|
||||
|
||||
- une montée de version d'un paquet déjà présent dans un lock, correctif d'advisory compris (décision de Max du 2026-10-17, consignée sur #456), et une arête re-résolue vers une version déjà présente : seul le garde `windows-sys` de la CI en surveille, pour ses sept crates ([ADR 0023](docs/adr/0023-windows-sys-pinning.md)) ;
|
||||
- dans `check-rust.yml` et `check-frontend.yml` : un déclencheur ajouté, sauf `pull_request_target`, une étape `run:` ajoutée qui ne fait que lire, un cache (`Swatinem/rust-cache`, `actions/cache`), un commentaire, la version de Node ;
|
||||
- `src/services/db.ts` et `src/services/dataExportService.ts`, même quand le diff touche le verrou, le changement de profil ou les suppressions de la restauration (décision de Max du 2026-10-18, revue de #460) : contre leurs défauts, la mutation du niveau normal est le bon outil ;
|
||||
- `src-tauri/src/commands/{fs_commands,backup_commands}.rs`, et le reste de `balance_commands.rs` ;
|
||||
- `src/shared/entitlements.ts` : la matrice ne verrouille que l'interface ([ADR 0017](docs/adr/0017-feature-gating-par-tier.md), § 2) ;
|
||||
- `CLAUDE.md`, `REVIEW.md` hors de la section « Niveaux », `docs/adr/**`, `docs/architecture.md`, `docs/qa-update-cycle.md` et `.claude/**` : sources de ce fichier et instructions des agents, jamais de niveau bas.
|
||||
|
||||
### Bas
|
||||
|
||||
Une PR est de niveau bas quand tous ses fichiers sont désignés par `**/*.md`, `docs/**`, `LICENSE`, `tasks/**` ou `reports/**`, hors des exceptions de la liste précédente. `STATE.md` reste bas malgré son import par `CLAUDE.md` : il résume l'état du projet, il ne fixe pas de règle.
|
||||
|
||||
### Normal
|
||||
|
||||
Tout le reste : code applicatif et tests, traductions (`src/i18n/locales/*.json`), `public/`, configuration TypeScript, `.github/**` (miroir dormant).
|
||||
|
||||
### Calibration
|
||||
|
||||
Rejouée le 2026-10-17 (#456) sur les PR mergées de #344 à #452 : les 39 PR de référence du document de départ du pilote, dont la liste est figée dans la PR qui ferme #456, et la plage complète, soit 51 PR.
|
||||
|
||||
| Ensemble | Bas | Normal | Haut |
|
||||
|---|---|---|---|
|
||||
| 39 PR de référence, motifs d'essai | 3 | 19 | 17 (44 %) |
|
||||
| 39 PR de référence, ces motifs | 3 | 28 | 8 (21 %, pour une cible de 20 %) |
|
||||
| 51 PR de la plage, ces motifs | 6 | 33 | 12 (24 %) |
|
||||
|
||||
Niveau haut sur les 51 : licence (#359, #364, #366, #368, #386, #387, #388), migrations et profils (#394, #436), release (#414, #416, #418) ; parmi les 39 de référence, #386, #387, #388, #394, #414, #416, #418 et #436. L'écart à la cible est justifié dans la PR qui ferme #456, qui consigne aussi le niveau de chaque PR. Le script qui les calcule est `scripts/review-levels.py` (API Forgejo, lecture seule) ; `python3 scripts/review-levels.py --check` vérifie, sans réseau, que ses motifs sont ceux du tableau Haut.
|
||||
|
||||
Une PR qui modifie cette section dit, pour chaque motif retiré ou assoupli, ce qu'il protégeait, met `scripts/review-levels.py` à jour dans le même diff et le rejoue sur les mêmes PR ; le pilote #458 mesure si les niveaux tiennent.
|
||||
|
||||
## Ce qui compte par zone
|
||||
|
||||
Chaque règle cite sa source. Elles valent quel que soit le niveau : une PR normale qui touche une zone les respecte aussi.
|
||||
|
||||
### Vie privée
|
||||
|
||||
- Aucune donnée financière ne quitte le poste : ni télémétrie, ni analytics, et le CSV comme la base restent locaux. Un appel réseau ajouté se justifie dans la PR : il n'envoie rien de financier ni aucun identifiant brut, et il passe derrière un consentement ou l'édition. — `CLAUDE.md` « Privacy-first — NON NÉGOCIABLE », [ADR 0009](docs/adr/0009-proxy-price-fetching-via-maximus-api.md), [ADR 0020](docs/adr/0020-identifiant-machine-derive.md)
|
||||
- Les appels sortants partent tous du Rust (`reqwest`) : `auth_commands.rs` (compte), `license_commands.rs` (licence et activation), `balance_commands.rs` (`fetch_price`, qui porte le jeton d'activation) et `feedback_commands.rs`, plus l'endpoint du plugin d'updater. Le `connect-src` de `app.security.csp` (`api.`, `auth.` et `feedback.lacompagniemaximus.com`) ne borne que la webview : lire chaque URL, chaque en-tête et chaque corps envoyé. — `CLAUDE.md` « Privacy-first — NON NÉGOCIABLE », `src-tauri/tauri.conf.json`, `src-tauri/src/commands/`
|
||||
- Les cours passent par `maximus-api`, après un consentement explicite par profil, avec les seuls en-têtes `Authorization`, `Accept` et `User-Agent` : le fournisseur tiers ne voit jamais le poste. Ce consentement n'est vérifié que côté front (`PriceFetchControl.tsx`, `PriceFetchConsentToggle.tsx`) : `fetch_price` ne le revérifie pas. — [ADR 0009](docs/adr/0009-proxy-price-fetching-via-maximus-api.md), `src-tauri/src/commands/balance_commands.rs`
|
||||
- Seule l'activation envoie un identifiant de la machine, et seulement le dérivé (`HMAC-SHA256` de l'identifiant du système) ; un jeton émis avant la dérivation porte encore l'identifiant brut jusqu'à la réactivation. — [ADR 0020](docs/adr/0020-identifiant-machine-derive.md), § 2 et 5
|
||||
- Le Feedback Hub reçoit le texte saisi, suivi au choix des dernières erreurs du Journal, un `userId` facultatif et un contexte borné par `FeedbackContext` (page, langue, thème, fenêtre, user-agent, horodatage) : un champ ajouté au contexte élargit ce qui part. — `CLAUDE.md` « Privacy-first — NON NÉGOCIABLE », `src/services/feedbackService.ts`, `src/components/settings/FeedbackDialog.tsx`
|
||||
- La webview n'a que les permissions de `src-tauri/capabilities/default.json` : l'accès aux fichiers passe par les commandes Rust, jamais par une permission `fs`, et une permission ajoutée se justifie. — `CLAUDE.md` « Règles d'architecture »
|
||||
- Un message du Journal nomme un fichier, jamais son dossier : le chemin contient le nom d'utilisateur, et une ligne d'erreur du Journal peut partir avec une rétroaction (règle posée pour le rattrapage des profils). — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md)
|
||||
|
||||
### Précision financière et import
|
||||
|
||||
- Montants : virgule ou point décimal, espaces, symboles monétaires, négatifs, cellules vides et formats inattendus ont chacun leur test. — `CLAUDE.md` « Précision financière » et « Conventions de code » (Général)
|
||||
- Encodages CSV : UTF-8, Windows-1252, ISO-8859-15. — `CLAUDE.md` « Précision financière »
|
||||
- Le format d'import est persisté en entier sur la source et lu tel quel, jamais re-deviné ; l'aperçu reste obligatoire avant tout import. — [ADR 0019](docs/adr/0019-format-import-persiste.md), `CLAUDE.md` « Fonctionnalités principales »
|
||||
|
||||
### Migrations, profils et données
|
||||
|
||||
- Jamais de modification d'une migration existante : son checksum est vérifié au démarrage et épinglé par le test `migration_checksums_match_the_released_ones`. On ajoute une version. — `CLAUDE.md` « Base de données », [ADR 0003](docs/adr/0003-sqlx-migrations.md), `.claude/rules/sql-migrations.md`
|
||||
- Une migration atteint deux chemins et reste correcte sur les deux : le profil par défaut, migré par tauri-plugin-sql (sqlx), et chaque profil secondaire, rattrapé à son ouverture par `prepare_profile_db` (rusqlite, clés étrangères actives). — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md), `.claude/rules/sql-migrations.md`
|
||||
- Tout changement de schéma et toute correction de données (seed, mots-clés, catégories) se reportent dans `consolidated_schema.sql` : un profil neuf naît avec l'historique complet et ne rejoue aucune migration. Aucun marqueur ajouté à `infer_floor`, réservé aux bases créées avant #390. — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md), `.claude/rules/sql-migrations.md`
|
||||
- Les comptes de tables, d'index et de migrations de `CLAUDE.md` et de `docs/architecture.md` suivent. — `CLAUDE.md` « Base de données » et « Documentation technique »
|
||||
- Les bases vivent sous `app_config_dir()`, qui n'est pas `app_data_dir()` sous Linux. Les deux dossiers dérivent de l'`identifier` de `tauri.conf.json` : le changer orpheline les bases des profils et, sous `app_data_dir()`, la clé de licence, le jeton d'activation et le fichier de repli des jetons OAuth. Le service du trousseau (`KEYCHAIN_SERVICE` de `token_store.rs`) est une constante alignée sur l'`identifier` par convention : il ne suit pas. — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md), [ADR 0006](docs/adr/0006-oauth-tokens-keychain.md), `CLAUDE.md` « Base de données », `src-tauri/src/commands/license_commands.rs`
|
||||
- Toute requête passe par le verrou unique de `db.ts`, et une transaction par `withTransaction` et son handle ; un changement de profil ferme puis rouvre la connexion. La restauration d'une sauvegarde (`runRestore` de `dataExportService.ts`) efface puis réinsère dans une seule transaction et ne notifie qu'après le `COMMIT`, une fois le verrou relâché. — [ADR 0022](docs/adr/0022-canal-evenements-donnees.md), [ADR 0005](docs/adr/0005-multi-profile-db.md), `docs/architecture.md` « Accès DB et transactions »
|
||||
- Un booléen se lie en `1`/`0` (`toSqlFlag`), jamais en `true`/`false`. — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md)
|
||||
- Une base par profil, PIN haché par Argon2. — [ADR 0005](docs/adr/0005-multi-profile-db.md)
|
||||
- Une nouvelle écriture d'une table que lit `COUNTERS_SQL` appelle `notifyDataChanged()` après succès, avec un test qui prouve la notification. — [ADR 0022](docs/adr/0022-canal-evenements-donnees.md)
|
||||
|
||||
### Jetons, licence et édition
|
||||
|
||||
- Jetons OAuth dans le trousseau du système ; repli sur un fichier `0600` seulement sans trousseau, et plus de retour au fichier une fois le trousseau utilisé. — [ADR 0006](docs/adr/0006-oauth-tokens-keychain.md)
|
||||
- Le callback `simpl-resultat://auth/callback` n'est traité que par l'instance qui détient le vérificateur PKCE : `tauri-plugin-single-instance` reste le premier plugin enregistré, et un callback sans `code` remonte une erreur au front. — [ADR 0006](docs/adr/0006-oauth-tokens-keychain.md), `docs/architecture.md` « OAuth2 et deep-link (Compte Maximus) »
|
||||
- Export chiffré SREF : AES-256-GCM, clé dérivée du mot de passe par Argon2id, sel et nonce aléatoires. — [ADR 0004](docs/adr/0004-aes-256-gcm-encryption.md)
|
||||
- La dérivation de l'identifiant machine est figée (`derive_machine_id`), et un jeton émis avant elle reste accepté. Une montée de `machine-uid` se vérifie : une forme renvoyée différente changerait l'identifiant dérivé de chaque machine. — [ADR 0020](docs/adr/0020-identifiant-machine-derive.md), § 1, 3 et « À surveiller »
|
||||
- `isEntitled` est fail-closed en Free : le court-circuit `free` passe avant l'override signé `features[]` (CWE-863). `useEntitlement` rend `{ allowed, ready }`. — [ADR 0017](docs/adr/0017-feature-gating-par-tier.md), § 1 et 3
|
||||
- Une fonctionnalité verrouillée reste visible, jamais masquée ; le gating bloque l'accès, jamais les données. — [ADR 0017](docs/adr/0017-feature-gating-par-tier.md), § 4 et 5
|
||||
- Le gating ne s'applique qu'à l'interface (soft-paywall assumé, l'app est GPL), et `SR_DEV_EDITION` n'existe que dans un build compilé avec la feature `dev-override`. — [ADR 0017](docs/adr/0017-feature-gating-par-tier.md), § 2 et 6
|
||||
|
||||
### Dépendances
|
||||
|
||||
- `.cargo/audit.toml` n'admet une advisory que si son crate est absent de toutes les cibles livrées (`cargo tree -i <crate> --target <triple>`, Linux et Windows, jamais `--target all`) ou si aucun correctif n'existe. Une entrée porte un ID, jamais un crate ; elle ajoute son crate à la liste du garde de `check-rust.yml` et a son issue de suivi, qui porte la condition de retrait. — [ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md), en-tête de `.cargo/audit.toml`, `CLAUDE.md` « CI/CD »
|
||||
- Garde `windows-sys` : s'il rougit après un `cargo update`, remettre les arêtes à la main selon l'ADR ; ne jamais modifier la liste gardée (`CRATES`), la version épinglée (`PIN`), l'ensemble attendu (`EXPECTED`) ni le canari pour le faire passer. Un `cargo update` ciblé peut déplacer d'autres arêtes sans le dire : chaque entrée qui bouge dans le lock doit s'expliquer. — [ADR 0023](docs/adr/0023-windows-sys-pinning.md), `CLAUDE.md` « CI/CD »
|
||||
- Une version montée exécute son nouveau code au build de la release, clé de signature dans l'environnement : scripts de build et macros des crates, code des paquets npm que lancent `npm run build` et `npx tauri build`. Lire ce que change la version, pas seulement son numéro. — `CLAUDE.md` « CI/CD », `docs/architecture.md` « `release.yml` — Build et publication »
|
||||
- Une advisory corrigée se prouve par `cargo audit` lancé depuis la racine, là où `.cargo/audit.toml` s'applique. `npm audit` n'est pas un gate de CI. — `CLAUDE.md` « CI/CD »
|
||||
|
||||
### Release et mise à jour
|
||||
|
||||
- `release.yml` met la clé de signature de l'updater (`TAURI_SIGNING_PRIVATE_KEY`) dans l'environnement des deux étapes `npx tauri build`. S'exécutent avec elle : `beforeBuildCommand` (`npm run build`, donc `tsc`, `vite.config.ts` et les `scripts` de `package.json`), `@tauri-apps/cli` et son binaire natif, qui lit la clé, `cargo-xwin` (`--runner cargo-xwin` pour Windows) et la toolchain Rust, installés à chaque tag sans version épinglée, `src-tauri/build.rs`, les scripts de build et les macros des crates, et tout module JS qu'une directive CSS `@plugin` ou `@config` fait charger. `npm ci` tourne avant, sans elle, mais ce qu'il installe s'exécute ensuite avec elle. — `CLAUDE.md` « CI/CD », `docs/architecture.md` « `release.yml` — Build et publication »
|
||||
- `latest.json` porte une clé par paquet (`linux-x86_64-deb`, `linux-x86_64-rpm`, `windows-x86_64`), contrôlée par `scripts/check-latest-json.jq` avant le `DELETE` du registre ; `scripts/build-latest-json.test.mjs` tourne dans `npm test`. — `CLAUDE.md` « CI/CD », `docs/architecture.md` « `release.yml` — Build et publication »
|
||||
- `release.yml` signe les binaires et publie le JSON d'updater que lisent toutes les installations : une clé publique de `plugins.updater` changée sans bascule prévue coupe leurs mises à jour, comme un endpoint déplacé. — `CLAUDE.md` « CI/CD » et « Fonctionnalités principales »
|
||||
- Une release qui touche l'updater, ses dépendances, `release.yml` ou la config `updater` exige un cycle réel de mise à jour (étape 10 de `/release`, `docs/qa-update-cycle.md`) : la PR le signale pour la release suivante. `verify-release.sh` contrôle la release publiée. — `CLAUDE.md` « CI/CD », `.claude/skills/release/SKILL.md`
|
||||
|
||||
### CI et gardes
|
||||
|
||||
- Aucun workflow `check-*` ne filtre sur `branches:` : `branches: [main]` avait laissé sans CI quatre des cinq PR empilées de la milestone feature-gating. `check-frontend.yml` exclut par `paths-ignore` pour qu'un fichier nouveau déclenche la CI au lieu de la sauter. — `CLAUDE.md` « CI/CD », en-têtes de `check-rust.yml` et `check-frontend.yml`
|
||||
- `audit.yml` est le seul canal des avis RustSec : quotidien et bloquant. L'étape `cargo audit` de `check-rust` est non bloquante par choix. — `CLAUDE.md` « CI/CD »
|
||||
- Une garde affiche chacune de ses vérifications, y compris celles qui passent, et lit un canari : une garde muette ne prouve rien. — [ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md) (règle 3), [ADR 0023](docs/adr/0023-windows-sys-pinning.md)
|
||||
- Une PR qui ne touche que `audit.yml` n'a aucune CI : les `paths` de `check-rust.yml` ne le listent pas, et le `paths-ignore` de `check-frontend.yml` l'exclut. Une PR qui ne touche que `release.yml` lance `check-frontend`, dont un test exécute le texte de l'étape « Publish latest.json to package registry » avec un `curl` factice (#419) ; le reste de `release.yml` ne s'exerce qu'au tag. La revue rejoue elle-même ce qui peut l'être. — `CLAUDE.md` « CI/CD », `check-rust.yml`, `check-frontend.yml`
|
||||
- Les secrets du dépôt (clé de signature et son mot de passe, jeton du registre de paquets qui remplace `latest.json`) sont lisibles par tout workflow : un workflow ajouté, une référence à `secrets` ajoutée ou un déclencheur `pull_request_target` se lit comme du code de release. — `CLAUDE.md` « CI/CD », `release.yml`
|
||||
|
||||
### i18n et documentation
|
||||
|
||||
- Toute chaîne visible passe par i18next, dans `fr.json` et `en.json` à la fois ; aucun texte en dur dans un composant. — `CLAUDE.md` « Internationalisation (i18n) », `.claude/rules/i18n.md`
|
||||
- Un changement visible ajoute une entrée sous `[Unreleased]` dans `CHANGELOG.md` et `CHANGELOG.fr.md`. — `CLAUDE.md` « Documentation technique » (Règle CHANGELOG)
|
||||
- Un changement d'usage met à jour `docs/guide-utilisateur.md` et les clés `docs.*` ; un service, un hook, une commande, une page ou une table de plus met à jour `docs/architecture.md` ; une décision structurante a son ADR. — `CLAUDE.md` « Documentation technique »
|
||||
|
||||
### Règles de revue
|
||||
|
||||
- Un changement qui rend faux un fait cité ici (sites d'appel réseau, hôtes de `connect-src`, commandes et déclencheurs de la CI, chemins du tableau Haut) met ce fichier à jour dans la même PR. — `CLAUDE.md` « Documentation technique »
|
||||
|
||||
## Commandes de vérification
|
||||
|
||||
Les workflows font foi ; ce tableau les résume au 2026-10-18. Le runner a une capacité de 1 : les jobs se suivent, et chaque maillon d'une pile a sa CI. — `CLAUDE.md` « CI/CD »
|
||||
|
||||
| Workflow | Déclencheur | Commandes |
|
||||
|---|---|---|
|
||||
| `check-frontend.yml` | PR, sauf si tous ses fichiers sont exclus : `src-tauri/**`, `.cargo/**`, `docs/**`, `reports/**`, `tasks/**`, `.github/**`, `*.md` à la racine, `LICENSE`, `check-rust.yml` et `audit.yml` | `npm ci`, `npm run build` (tsc + vite), `npm test` (vitest, `scripts/build-latest-json.test.mjs` compris) |
|
||||
| `check-rust.yml` | PR qui touche `src-tauri/**`, `.cargo/**` ou ce workflow ; lundi 06:30 UTC sur `main` ; `workflow_dispatch` | `cargo check --manifest-path src-tauri/Cargo.toml --all-targets`, garde des advisories supprimées, garde `windows-sys`, `cargo test --manifest-path src-tauri/Cargo.toml --all-targets`, `cargo audit --file src-tauri/Cargo.lock` (non bloquant) |
|
||||
| `audit.yml` | chaque jour à 06:00 UTC ; `workflow_dispatch` | `cargo-audit audit --file src-tauri/Cargo.lock` (bloquant) |
|
||||
| `release.yml` | tag `v*` | build Linux (`.deb`, `.rpm`) et Windows (cargo-xwin), signature, `latest.json` |
|
||||
|
||||
`cargo check` et `cargo test` tournent sans `--locked` dans la CI ; #449 l'ajoute, et sa PR met ce tableau à jour.
|
||||
|
||||
En local, les mêmes commandes, depuis la racine :
|
||||
|
||||
```bash
|
||||
npm ci && npm run build && npm test
|
||||
cargo check --manifest-path src-tauri/Cargo.toml --all-targets
|
||||
cargo test --manifest-path src-tauri/Cargo.toml --all-targets
|
||||
cargo audit --file src-tauri/Cargo.lock # depuis la racine : .cargo/audit.toml s'applique
|
||||
```
|
||||
|
||||
- `npm test` lancé à la racine parcourt aussi `.claude/worktrees/` (`vite.config.ts` ne l'exclut pas) : vider ce dossier avant, ou lancer la suite depuis le worktree. — `.claude/skills/release/SKILL.md` (étape 0)
|
||||
- Les gardes de `check-rust.yml` se rejouent en exécutant le bloc `run:` de leur étape depuis la racine, sous `bash --noprofile --norc -e -o pipefail`, le shell du runner dans un conteneur `ubuntu:22.04`.
|
||||
- Windows, pour une dépendance, une feature ou du code sous `cfg(windows)`, sur un poste où cargo-xwin est déjà installé : `cargo xwin check --manifest-path src-tauri/Cargo.toml --locked --target x86_64-pc-windows-msvc`, précédé d'un témoin sur `main` (un témoin rouge met l'outil en cause, pas la PR). Installer cargo-xwin vaut acceptation de la licence du CRT et du SDK Windows de Microsoft : une revue ne l'installe jamais. (#455)
|
||||
- Un `npm test` rejoué hors CI ne compte qu'après une sonde : un test volontairement rouge doit le faire sortir en code non nul.
|
||||
|
||||
## Pièges de revue
|
||||
|
||||
Les trois premiers viennent du skill global `pr-review`, corrigés pour ce dépôt.
|
||||
|
||||
- **CI d'une pile.** Chaque maillon a sa CI depuis #232 : la section « CI coverage gap » du skill (CI réservée aux PR qui ciblent `main`) ne vaut pas ici. `check-rust` ne tourne que si le diff du maillon touche ses chemins : l'état Rust d'une pile est prouvé par le dernier maillon qui en a touché. Exception : `fs_commands.rs` compile par `include_str!` les deux CSV de `src/__fixtures__/samples/`, hors de ces chemins ; une PR qui les renomme, les déplace ou les supprime casse `cargo check` sans que `check-rust` tourne. — `CLAUDE.md` « CI/CD », `src-tauri/src/commands/fs_commands.rs`
|
||||
- **Un filtre `paths:` n'est pas prouvé par une PR qui touche le chemin exact.** Le glob (`src-tauri/**`) l'est par une PR qui touche un autre fichier du dossier, comme la PR #344 pour `check-rust`. — skill `pr-review` (section CI), #232
|
||||
- **CI Rust et revue en fork.** Un fork qui attend la CI pour poster son verdict peut s'arrêter sans rien publier (PR #303) : poster les constats, surveiller le statut depuis la session principale, relancer la revue au vert. `check-rust` prend environ 3 min 30 avec le cache et 10 min à froid, sans compter la file du runner. — skill `pr-review` (délégation), #234
|
||||
- **Un vert de `check-rust` ne dit rien de l'audit** : son étape `cargo audit` est `continue-on-error`. Lire le journal : `cargo-audit` écrit `1 vulnerability found` mais `N vulnerabilities found` (chercher `vulnerabilit(y|ies) found`), et le journal de `check-rust` contient des codes ANSI à retirer avant tout `grep`. Un `audit.yml` vert veut dire « zéro advisory hors de la liste acceptée », pas « zéro advisory ». — `CLAUDE.md` « CI/CD », [ADR 0018](docs/adr/0018-suppression-advisories-non-atteignables.md), #447
|
||||
- **Windows n'est compilé qu'à la release.** La CI des PR ne compile que pour Linux : sans `cargo xwin check`, une casse Windows apparaît le jour du tag. — `docs/architecture.md` « `release.yml` — Build et publication », #455
|
||||
- **Tests sans jsdom.** Un rendu par `renderToStaticMarkup` ne lance ni effet ni clic : ce qui en dépend n'est prouvé que par un test qui passe par `src/test/hookHarness.ts` (effets joués par `commit()`, gestionnaires appelés depuis l'arbre rendu). Le harnais ne rend pas de nouveau après un setter et laisse les enfants à l'état d'éléments : lire ce que le test exerce avant de le créditer. — `docs/architecture.md` « Tests (vitest sans jsdom) »
|
||||
- **Faux `t`.** Un faux `t` qui rend la clé brute rend invisible un `t` oublié : les tests prennent `src/test/i18nMock.ts`, gardé par `src/__integration__/i18n-mock-guard.test.ts`. Jusqu'à #453, aucune garde ne détecte une clé présente dans une seule langue : comparer `fr.json` et `en.json`. — `docs/architecture.md` « Faux `t` marqué », #389
|
||||
- **Sous-chaîne du markup.** `not.toContain("disabled")` sur le HTML rendu bute sur une classe Tailwind (`disabled:opacity-50`) : tester l'attribut (`disabled=""`). — PR #452
|
||||
- **Booléen lié en texte.** tauri-plugin-sql stocke un booléen JS en texte `'true'`, qu'une condition `= 1` ne trouve pas : défaut resté invisible de la v0.1.0 à #390. — [ADR 0021](docs/adr/0021-migrations-profils-secondaires.md)
|
||||
- **Transaction hors verrou.** Un `BEGIN` et son `COMMIT` envoyés par deux `db.execute` peuvent partir sur deux connexions du pool et laisser un verrou zombie (« database is locked », v0.10.0) : toujours `withTransaction` et son handle. — `docs/architecture.md` « Accès DB et transactions »
|
||||
- **Dépôt public.** Aucune coordonnée d'un tiers (courriel, adresse, téléphone), aucune clé de licence ni aucun jeton dans un fichier commité, `STATE.md`, `docs/` et `.claude/skills/` compris ; un testeur se désigne par son identifiant Logto. — `.claude/skills/issue-license/SKILL.md`
|
||||
80
STATE.md
80
STATE.md
|
|
@ -1,8 +1,8 @@
|
|||
# STATE — Simpl'Résultat
|
||||
|
||||
> Derniere MAJ : 2026-09-04 (**#343/#344 — l'audit quotidien était rouge depuis 17 jours et personne ne l'avait vu.** `audit.yml` échouait tous les matins depuis le 2026-08-19 (runs 375→391, dernier vert le 08-18) sur **une seule** vulnérabilité : RUSTSEC-2026-0258, `h2` 0.4.13, publiée le 08-17 — déni de service de sévérité faible, les frames HTTP/2 `DATA` vides sont mises en file sans limite, donc mémoire non bornée et panique possible sur dépassement. Aucun push sur `main` dans la fenêtre : c'est l'advisory-db qui a bougé, pas le code. **Atteignable sur les deux cibles livrées**, par deux chemins (`tauri-plugin-updater`→`reqwest 0.13.2` pour l'updater, et le `reqwest 0.12.28` direct de l'app pour licence/auth/cours/rétroaction) → règle 1 de `.cargo/audit.toml` : on corrige, on ne supprime pas. `h2` → **0.4.19**, `Cargo.toml` intouché, 666 paquets avant/après, `.cargo/audit.toml` et le garde-fou inchangés. **Le lock ne bouge que de 2 lignes** : `cargo update -p h2` réaiguille au passage les 7 pointeurs `windows-sys` de `0.61.2` vers 0.48/0.52/0.59 — la dérive même que #321 avait défaite 3 semaines plus tôt — donc seule la strophe `h2` a été appliquée, après re-vérification **crate par crate** contre `git show v0.14.0:src-tauri/Cargo.lock` plutôt que sur la foi de la note de #321. `/pr-review` **APPROVE**, et il a trouvé le point manquant : 0.4.19 resserre sa borne `http` de `^1` à `^1.1`, ce qui est exactement ce qui invalide un lock épinglé à la main — satisfait ici (`http 1.4.0`), plus checksum authentifié par `sha256sum` du `.crate`. **Audit vert sur `main`** prouvé par `workflow_dispatch` (run 393, `Job succeeded`, 666 crates, 22 warnings autorisés, 0 vulnérabilité), pas déduit du run de PR. Glob `src-tauri/**` **enfin prouvé** — le suivi ouvert depuis #232 peut être fermé. Nouveau suivi **#345**. Candidate **v0.15.0**.)
|
||||
> Derniere MAJ : 2026-10-18 (**Étape 10 faite sous Linux et #421 fermée — cycle réel v0.17.1 → v0.17.2 réussi (`.deb`, invite de mot de passe vue, type non consigné), trace dans le corps de la release ; #421 vérifiée sous Linux par Max, fermée à la main (commentaire 25074).** Restent les cycles Windows et `.rpm`, et les vérifications dans l'app de #369 et des retours de la v0.17.1, non confirmées à ce jour. Aucune migration (v1→v17).)
|
||||
>
|
||||
> Précédente MAJ : 2026-08-15 (**PRs #321 et #322 reviewées, corrigées et mergées ff-only** (`main` `97d376b`) — les deux dormaient depuis 18 jours, chacune avec une revue du 2026-08-14 non traitée. **#321 APPROVE** : `plist 1.10.0` remonte `quick-xml` à `0.41.0`, la version corrigée, dans la borne existante de `tauri` → RUSTSEC-2026-0194/-0195 **résolues, plus acceptées**, `.cargo/audit.toml` réduit à `rsa` seul, `deep-link` dé-yanké. Point **inversé** par la revue de re-vérification : les 7 réaiguillages `windows-sys` **remontent** à `0.61.2`, soit l'état exact du tag `v0.14.0` (vérifié crate par crate) — c'était `main` qui portait la configuration jamais compilée sous Windows, la PR la supprime au lieu de l'ajouter. **#322 REQUEST_CHANGES puis corrigée avant merge** : la checklist QA inversait la séquence d'états Linux — `downloadAndInstall` télécharge **et** installe dans le même appel (`updater.rs:723-729`, `Finished` no-op en `useUpdater.ts:119-121`), donc l'invite pkexec s'ouvre pendant « Téléchargement en cours… » et `readyToInstall` n'arrive qu'**après** `dpkg -i` ; l'ordre des cases **neutralisait le prérequis polkit** que la page existe pour imposer (sans agent l'app se fige en `downloading`, le testeur rapporte « le téléchargement bloque »). #312/#313/#315 fermées. **#314 est caduque** : `audit.yml` tire tous les jours à 06:00 UTC, **19 runs `schedule` verts** depuis le 2026-07-28 — l'issue a été écrite le 07-27 au soir, avant que le premier tir programmé ait pu exister. CI runs 370/371 vertes, garde-fou d'audit tracé (`Suppression guard: 2 checks, exit 0`), **111 Rust**, 0 vulnérabilité. Candidate **v0.15.0**. Rappels infra : `main` est **protégée** (whitelist push `maximus`) + remote ancré `https://maximus@…`)
|
||||
> Précédente MAJ : 2026-10-18 (**Relevé du pilote #458 à 4 PR sur 20 : #464 et #465 ajoutées au tableau (commentaire 24819), niveau haut jugé juste par Max pour les deux ; rangs corrigés ici (#450 était la deuxième, #464 est la troisième, #465 la quatrième).** Les PR mergées depuis la v0.17.1 sont marquées publiées dans la v0.17.2. Vérifications dans l'app et étape 10 en cours chez Max, concluantes à date. Aucune migration (v1→v17).)
|
||||
|
||||
## Position actuelle
|
||||
|
||||
|
|
@ -10,29 +10,75 @@ v0.9.1 shippée (2026-05-10). Milestones `spec-refonte-rapports`, `spec-refonte-
|
|||
|
||||
Audit critique de la page Bilan livré (`docs/audit-bilan-2026-05.md`, revue CPA + UX). Étape 0 — quick wins terminologie « catégorie »→« type », symbole optionnel, date de snapshot déplaçable — mergée (#201, issues #198/#199/#200 fermées). Chantier structurel = Étapes 1-2 de l'audit (séparation véhicule fiscal × classe d'actif via `vehicle_type`, puis détail par titre `balance_securities`+holdings+`book_cost`, bascule agrégé→détaillé, réouverture ADR 0012). **Étape 1 livrée** (sprint 2026-06-01 : PRs #206-#209 mergées, milestone `overnight-2026-06-01-bilan-axe-vehicule` fermée 4/4) — `vehicle_type` nullable = enveloppe fiscale portée par le compte, catégorie = pure classe d'actif (5 classes), migrations additives v12/v13 (v1-v11 intactes), renommage via `custom_label` (fix bug I), axe graphique classe/enveloppe + rendements repliables persistés ; ADR 0014 Accepted, ADR 0012 Rejected. CHANGELOG sous [Unreleased] (pas encore taggé). **Étape 2 livrée** (merge manuel 2026-06-09 : pile de 9 PRs stackées #219-#227 mergées bottom-up, milestone `overnight-2026-06-05-bilan-detail-titres` à 9/10, issues #210-#218 fermées) — détail par titre via `balance_securities` + `balance_snapshot_holdings`, `balance_accounts.kind` ('simple'|'detailed') + `detailed_since`, migrations additives v14/v15/v16 (v1-v13 intactes), conversion des comptes cotés existants en détaillés 1-position (v16), service securities transactionnel, reducer holdings + dispatch `account.kind`, UI multi-titres (SecurityPicker), assistant détailler-un-compte (date pivot), drill-down par titre + gain latent, tests intégration/régression ; ADR 0015 Accepted. **#228 mergé** (PR #229, fix-forward : garde d'abort v16 scopée aux comptes convertibles via `JOIN balance_categories` + `c.asset_type IS NOT NULL` dans les 3 copies — Migration v16 / V16_SQL / V16_CORRUPT — + test régression ; CI vert car #229 ciblait `main`) → milestone `overnight-2026-06-05-bilan-detail-titres` complète **10/10 et fermée**. **v0.10.0 shippée** (2026-06-29 : Étapes 1+2 du bilan, migrations v12→v16) **puis hotfix v0.10.1** (2026-06-30, PR #230, déployé) — corrige un « database is locked » introduit en 0.10.0 (apparaissait après l'abandon d'un snapshot en cours) : tout l'accès DB est désormais sérialisé via `withTransaction` dans `db.ts` (tauri-plugin-sql = pool sqlx multi-connexions sans primitive de transaction JS → `BEGIN`/`COMMIT` en `db.execute` séparés pouvaient strander une transaction d'écriture = verrou zombie), appliqué aux 5 sites transactionnels ; + console de log live-update + API `logInfo/logWarn/logError`. 20 tables / 24 index, 16 migrations (v1→v16).
|
||||
|
||||
**Milestone `deps-security-2026-07` livrée** (2026-07-01, 4/4 fermée) — 4 vulns de dépendances Defenseur remédiées en 2 PRs (#239 `react-router-dom` 7.18.1 ; #240 `vite` 6.4.3 + `vitest` 4.1.9) ; `npm audit` 5→**0** — le dernier `@babel/core` low remédié le 2026-07-04 (override scopé `@vitejs/plugin-react`→`@babel/core@^7.29.7`, issue #241 / PR #242 mergée). **Ce « 0 » n'est plus l'état courant** : des advisories publiées depuis l'ont ramené à 3, traitées en #311 le 2026-07-27 → **2 restantes, acceptées** (`react-router`, cf. entrée du jour). Nouveau milestone `spec-ci-build-optimization` (2/4) ouvert (baseline #231 : cause #2 = `reserveCache timeout` du runner ; #234 connectivité cache VPS, #232 split workflows).
|
||||
**Milestone `deps-security-2026-07` livrée** (2026-07-01, 4/4 fermée) — 4 vulns de dépendances Defenseur remédiées en 2 PRs (#239 `react-router-dom` 7.18.1 ; #240 `vite` 6.4.3 + `vitest` 4.1.9) ; `npm audit` 5→**0** — le dernier `@babel/core` low remédié le 2026-07-04 (override scopé `@vitejs/plugin-react`→`@babel/core@^7.29.7`, issue #241 / PR #242 mergée). **Ce « 0 » n'a pas tenu** : des advisories publiées depuis l'ont ramené à 3 (#311, 2026-07-27, 2 acceptées sur `react-router`), puis à 8 ; **0 de nouveau au 2026-10-10** par le rafraîchissement du lock de #358 (mergé le 2026-10-12), qui ferme aussi #317. Nouveau milestone `spec-ci-build-optimization` (2/4) ouvert (baseline #231 : cause #2 = `reserveCache timeout` du runner ; #234 connectivité cache VPS, #232 split workflows).
|
||||
|
||||
**v0.11.0 shippée** (2026-07-05, tag `v0.11.0` → CI release Windows/Linux + JSON updater) — milestone `overnight-2026-07-05-bilan-rapports-ux` complétée **5/5 et fermée** via un run `/autopilot` (5 workers séquentiels, PRs #248-#252 toutes `/pr-review` APPROVE, puis merge local de la pile + réconciliation Forgejo). Contenu : rapport comparable réel-vs-réel **hiérarchique** avec sous-totaux (#247) ; **netting des transferts** ciblé (catégories type `transfer` → SUM signé ~0, autres types inchangés au byte) dans le compare (#243) ; landing Bilan en **tuiles** `HubReportNavCard` + guard empty-state découplé + gestion comptes accessible partout (#244) ; **import CSV de titres** dans un snapshot détaillé (front pur, `autoDetectHoldingColumns`, prix flexible) (#245) ; cible de **migration catégories éditable** sur chaque ligne + type-ahead `CategoryCombobox` feuilles-seulement (#246). Aucune migration DB (v1→v16 inchangées), 20 tables / 24 index, 16 migrations. Point de suivi non bloquant : nets de transfert signés traversent du code UI compare pensé pour des magnitudes positives (mord seulement les transferts déséquilibrés à une jambe).
|
||||
|
||||
**v0.12.0 shippée** (2026-07-05, tag `v0.12.0` → CI release #311) — recadrage de #243 (montants de transfert inclus dans le comparable) par Max en « analyse de résultat » : deux rapports comparables refondus en états de résultat (income statement). Le compare réel-vs-réel (#253) et le tableau de tendance par catégorie (#256) affichent le **revenu**, un **Résultat avant transferts** (revenus − dépenses) et un **Résultat net** (après transferts), sections ordonnées revenu→dépense→transfert ; les rapports comparables ouvrent par défaut sur le **mois complet précédent** (#253). Modules purs extraits et testés (`compareResults.ts`, `overTimeResults.ts`) ; 2 blocages `/pr-review` corrigés (fuite du revenu dans les top-movers Cartes ; garde StrictMode sur le sync du mois de référence). Livré en 2 issues/PRs stackées #255→#257 (merge local + réconciliation Forgejo, branches supprimées), 701 vitest verts, aucune migration DB (v1→v16 inchangées), 20 tables / 24 index. Backlog ouvert au passage : #258 (db-locked `proposeStarterAccounts`) → fermé le 2026-07-07 (déjà corrigé v0.10.1, pas de nesting) ; #254 (collapse/expand) → livré le 2026-07-07 (PR #261) ; #259 (mapping manuel d'un compte standard sans similaire auto) et #260 (epic « rapports uniformes » — tous les rapports sur le modèle income-statement) ouverts.
|
||||
|
||||
**v0.15.0 shippée** (2026-09-12, tag `v0.15.0` → CI release run 402, 7 artefacts + `latest.json` vérifiés) — première version où le gating par édition est actif : sans clé, Budget, Ajustements, rapports avancés, profils multiples, Bilan et mises à jour automatiques sont verrouillés (données intactes, ADR 0017). Les deux testeurs (Max, Michel) ont une clé Premium émise avant le tag. `[Unreleased]` vide. Aucune migration DB nouvelle (v1→v17). Étape 10 de `/release` **consignée le 2026-09-13** : cycle réel v0.14.0 → v0.15.0 validé par les deux testeurs, trace rédigée pour le corps de la release Forgejo (invite polkit Linux non consignée, `.rpm` hors test → #320).
|
||||
|
||||
**v0.16.0 shippée** (2026-10-03, tag `v0.16.0` → CI release run 451, 7 artefacts + `latest.json` vérifiés, registre de l'updater en 0.16.0) — première version vendable de bout en bout : « Obtenir Base » ouvre la page produit (#346), le client de licence parle `/v1` et nomme chaque refus (#270), l'édition ne dépend que de la clé (#347), l'activation envoie un identifiant dérivé (#350), une clé révoquée retombe en Gratuite après un contrôle au démarrage (#365), et `rustls` 0.23.45 (#348). `[Unreleased]` vidé au tag. Aucune migration DB nouvelle (v1→v17). Sur la release publiée : #365 vérifiée et fermée le 2026-10-03, étape 10 de `/release` faite sous Linux le même jour (trace dans le corps de la release) ; étape 10 sous Windows faite par Michel (confirmée le 2026-10-10) ; #350 fermée et #346 vérifiée le 2026-10-04.
|
||||
|
||||
**Onboarding phase 1 mergée sur `main`** (2026-10-04, `667e058`, publiée dans la v0.17.0) — milestone `planned-2026-10-03-onboarding-phase1` fermée 7/7 (#351-#357, PRs #377-#383, produites par `/autopilot` le 2026-10-03 puis relues une à une par `/pr-review`) : états vides guidés (dashboard, Import, Transactions, Budget), liste de démarrage à 6 cases calculée depuis la base, bouton « Créer un dossier d'exemple », guide « Exporter un CSV depuis votre banque » (Desjardins, Tangerine, RBC, BNC, BMO). Aucune migration DB (v1→v17). Suivis : #384 (non bloquants) et #371 (BMO sur compte réel, saisie de Max).
|
||||
|
||||
**Suites de licence mergées sur `main`** (2026-10-05, `2cbc886`, publiées dans la v0.17.0) — milestone `planned-2026-10-03-suites-licence` fermée 3/3 (#373, #370, #367 ; PRs #386 → #388, produites par `/autopilot` le 2026-10-04, relues par trois `/pr-review` APPROVE, suivis appliqués en tête de pile) : contrôle d'une clé révoquée dès sa saisie (au plus une fois par 24 h et par clé), « Retirer la clé » dans la carte Licence (place libérée d'abord sur une machine activée, contrôle quotidien arrêté), jeton d'une ancienne licence retiré sur un refus des cours ou à l'ouverture de la liste des machines. Aucune migration DB (v1→v17). Suivi : #389 (huit autres fichiers de tests au faux `t` brut).
|
||||
|
||||
**v0.17.0 shippée** (2026-10-05, tag `v0.17.0` sur `07255e1` → CI release run 491 en 22 min, 7 artefacts + `latest.json` vérifiés par `verify-release.sh`, endpoint de l'updater en 0.17.0) — onboarding phase 1 et suites de licence. `[Unreleased]` vidé au tag. Aucune migration DB nouvelle (v1→v17). Étape 10 de `/release` non exigée (chaîne de mise à jour inchangée), raison écrite sur la release. Vérifications manuelles des suites de licence faites sur la release publiée (confirmées le 2026-10-10).
|
||||
|
||||
**Parcours de démarrage guidé mergé sur `main`** (2026-10-08, `ee17f31`, publié dans la v0.17.1) — milestone `planned-2026-10-06-onboarding-parcours-guide` fermée 6/6 (#396-#401, PRs #402 → #407, produites par `/autopilot` le 2026-10-08, relues par six `/pr-review` APPROVE, suivis appliqués en tête de pile) : bandeau « Démarrage n/N · Étape suivante » sur chaque page, cartes d'étape avec mise en évidence du contrôle sur Import, Transactions et Budget, état partagé par un fournisseur et canal d'événements de données (ADR 0022), règle D9 (un profil déjà actif trouve le parcours masqué). Aucune migration DB (v1→v17). Suivis : #408 (`db.ts` hors verrou pendant une transaction) et #409 (harnais de test).
|
||||
|
||||
**v0.17.1 shippée** (2026-10-08, tag `v0.17.1` sur `e4db715` → CI release run 508, 7 artefacts + `latest.json` vérifiés par `verify-release.sh`, endpoint de l'updater en 0.17.1) — parcours de démarrage guidé et #390 (étape 5 de la liste, rattrapage des migrations des profils secondaires, dossier des bases sous Linux). Numéro de correctif choisi par Max malgré les entrées Added. `[Unreleased]` vidé au tag. Aucune migration DB nouvelle (v1→v17). Étape 10 de `/release` non exigée (chaîne de mise à jour inchangée), raison écrite sur la release. Vérifications manuelles faites sur la release publiée (parcours guidé, #390), complétion confirmée le 2026-10-10.
|
||||
|
||||
**Chaîne de release et dépendances mergée sur `main`** (2026-10-12, `9e8c41b`, publiée dans la v0.17.2) — milestone `planned-2026-10-10-release-deps` fermée 4/4 (#376, #345, #320, #358, plus #317 ; PRs #414 → #417 produites par `/autopilot` le 2026-10-10 et relues par quatre `/pr-review` APPROVE, puis PR #418 de suivis des revues, relue à son tour) : Node 24 dans les trois workflows encore en Node 20 ; garde `Verify windows-sys pinning` dans `check-rust` (ADR 0023) ; `latest.json` avec une clé par paquet Linux (`linux-x86_64-deb`, `linux-x86_64-rpm`) et sans clé générique, contrôlé avant le `DELETE` du registre par `scripts/check-latest-json.jq` ; lock npm rafraîchi, `npm audit` à 0. Aucune migration DB (v1→v17). `[Unreleased]` porte un Corrigé (#320) et un Sécurité (#358). Suivi : #419 (point 1 avant le prochain tag).
|
||||
|
||||
**#423 mergée sur `main`** (2026-10-12, `8898213`, publiée dans la v0.17.2) — les champs « Catégorie parente » (formulaire de catégorie) et « Catégorie » (dialogue « Nouveau mot-clé ») utilisent `CategoryCombobox` : saisie qui filtre, ordre hiérarchique, noms du seed traduits. Liste des parents : racines et niveau 1, moins la catégorie éditée, profondeur réduite de la hauteur de son sous-arbre (pas de 4ᵉ niveau), parent courant toujours gardé ; seule garde aujourd'hui, `updateCategory` ne vérifie rien (#393). Le dialogue mot-clé prend la liste des Transactions (actives et saisissables, D2) et ne garde la catégorie pré-remplie que si elle en fait partie. `[Unreleased]` porte un Modifié. Suivi : #429. Aucune migration (v1→v17).
|
||||
|
||||
**Fiabilité des tests et de `db.ts` mergée sur `main`** (2026-10-13, `c17bf5d`, publiée dans la v0.17.2) — milestone `planned-2026-10-10-fiabilite-tests` fermée 4/4 (#408, #409, #389, #395 ; PRs #433 → #436 produites par `/autopilot` le 2026-10-12, relues par quatre `/pr-review` APPROVE, cinq commits de suivi en tête de pile) : `withTransaction` remet au rappel son propre handle, révoqué à la fin, toute autre requête attend derrière le verrou ; changement de profil en une seule section exclusive avec numéro de génération ; alerte Journal après 15 s d'attente ; harnais `src/test/hookHarness.ts` et faux `t` marqué `src/test/i18nMock.ts`, gardé par `src/__integration__/i18n-mock-guard.test.ts` ; le Journal nomme la copie d'un rattrapage arrêté et ne contient plus de chemin, une seule copie gardée par profil, la page d'erreur du démarrage disparaît quand le profil s'ouvre après le délai ; badge « non garanti » traduit. `[Unreleased]` porte un Modifié et deux Corrigé de plus. Suivi : #437. Aucune migration (v1→v17).
|
||||
|
||||
**Retours d'onboarding de la v0.17.1 mergés sur `main`** (2026-10-15, `934bcbf`, publiés dans la v0.17.2) — milestone `planned-2026-10-12-retours-onboarding-v0171` fermée 8/8 (#424, #422, #427, #426, #430, #431, #425, #432 ; PRs #438 → #445 produites par `/autopilot` le 2026-10-13, relues par huit `/pr-review` APPROVE sans bloquant) : grille Budget émise en profondeur et sous-totaux en bas rattachés à leur bloc (`reorderRows`, corrige aussi Budget vs réel et Comparables) ; encadré « Structure attendue » (un sous-dossier par compte), avertissement des CSV posés à la racine (`scan_import_folder` → `ImportFolderScan`), nom de source en lecture seule = sous-dossier ; cartes Transactions (« Ajouter comme mot-clé ») et Budget (feuilles, colonne Annuel) ; aide et guide périmés corrigés ; parcours à huit lignes avec deux étapes de visite (Catégories, Rapports) cochées par « J'ai compris » ou un clic sur Tendances, initialisées une fois par profil sans transaction (ADR 0022 amendé). `[Unreleased]` porte des Added, Changed et Fixed de plus. Suivi : #446. Aucune migration (v1→v17).
|
||||
|
||||
**#447 mergée sur `main`** (2026-10-16, `1763a9f`, publiée dans la v0.17.2) — `rustls` 0.23.45 → 0.23.46 (PR #448 : deux lignes de `Cargo.lock`, aucun bump compagnon, aucune arête `windows-sys` déplacée) corrige RUSTSEC-2026-0346 à -0349, publiées le 2026-10-14, qui rougissaient `audit.yml` depuis le 2026-10-15 sans que le lock ait bougé depuis la v0.17.1. Aucune des quatre n'est atteignable (`rustls` n'est atteint que par `tauri-plugin-updater` → `reqwest` 0.13), mais la règle 1 de `.cargo/audit.toml` demande de corriger plutôt que de supprimer ; `audit.yml` est de nouveau vert sur `main` (run 556). Pas de release corrective : le bump part avec le prochain tag, qui devra dérouler le cycle réel de mise à jour. `[Unreleased]` porte un second Sécurité. Suivis : #449 et agent-defenseurs#186. Aucune migration (v1→v17).
|
||||
|
||||
**#369 mergée sur `main`** (2026-10-17, `a8c3fd6`, publiée dans la v0.17.2) — la Répartition (bouton de la page Transactions) est réservée à l'édition Base, comme l'écran de déverrouillage le promettait (`upsell.features.adjustments`) : en Gratuite, le bouton garde sa place, porte un cadenas et ouvre l'écran verrouillé dans une fenêtre (`UpsellDialog`) au lieu de la fenêtre de répartition, pour toute ligne ; tant que la licence n'est pas `ready`, bouton inactif, ni cadenas ni fenêtre ; les répartitions existantes restent comptées et gardent l'icône orange. Pièces : `splitAccessFor` (`src/shared/splitGate.ts`, `open`/`locked`/`pending`, plus strict que `profileGate` : `allowed` ne court-circuite pas `ready`) et la constante `SPLIT_FEATURE`, `SplitButton`, `UpsellDialog`, prop `splitAccess` obligatoire de `TransactionTable`, calculée par `TransactionsPage` depuis `useEntitlement(SPLIT_FEATURE)` ; ADR 0017 amendé, guide, docs intégrées FR/EN et `architecture.md` à jour, `[Unreleased]` porte un Modifié de plus. Suivis : #451 (fenêtre d'incitation de `ProfileSwitcher` sur `UpsellDialog`, Échap, focus) et #454 (reclasser une transaction répartie laisse sa ligne de compensation dans l'ancienne catégorie, irréparable en Gratuite depuis #369). Aucune migration (v1→v17).
|
||||
|
||||
**`REVIEW.md` mergé sur `main`** (2026-10-18, `24da72b`) — niveau d'une PR (haut, normal, bas) fixé par les fichiers que touche son diff, jamais par son auteur ; tableau Haut de 21 lignes (licence et jetons, migrations et bases de profils, release et code exécuté avec la clé de signature, dépendances qui changent la confiance, CI qui pourrait passer au vert à tort, `.claude/settings.json` et `.mcp.json`, et une règle qui échoue fermée pour tout fichier ajouté, renommé ou supprimé à la racine ou directement dans `src-tauri/`) ; neuf zones, chaque règle sourcée par une section du `CLAUDE.md` ou un ADR ; commandes de vérification ; onze pièges, dont les trois du skill global `pr-review`, corrigés pour ce dépôt ; script de calibration versionné `scripts/review-levels.py` (`--check` compare ses motifs au tableau). `CLAUDE.md` y renvoie. Depuis #457 (fermée le 2026-10-18), `/pr-review` le lit sur `main` et annonce le niveau en tête de chaque verdict ; le pilote #458 mesure les 20 PR suivantes, la première étant #459 ; relevé au 2026-10-18 (commentaire 24819) : 4 sur 20, #459, #450, #464 et #465, toutes de niveau haut, jugé juste par Max sauf pour #450 (trop haut, #462). Suivis : #461 et #462. Aucune migration (v1→v17).
|
||||
|
||||
**#455 mergée sur `main`** (2026-10-18, `c5efc38`, publiée dans la v0.17.2) — `tauri-plugin-updater` compilé sans sa feature `zip` (`default-features = false`, `rustls-tls` gardé) : `zip`, `arbitrary` et `derive_arbitrary` sortent du lock (666 → 663 paquets, aucun ajouté), et avec eux RUSTSEC-2026-0367 à -0369, retirés et non supprimés dans `.cargo/audit.toml` ; `tar` quitte le graphe Linux, d'où le canari de la garde des suppressions de `check-rust` passé de `tar` à `webkit2gtk` (ADR 0018 amendé ; `architecture.md`, `docs/qa-update-cycle.md` et skill `/release` à jour). `[Unreleased]` porte un troisième Sécurité. `audit.yml` vert sur `main` (run 564). Suivi : #463. Aucune migration (v1→v17).
|
||||
|
||||
**#419 mergée sur `main`** (2026-10-18, `c859405`, publiée dans la v0.17.2) — `scripts/check-latest-json.jq` exige exactement un document (`length == 1 and (.[0] | …)`) et l'étape « Publish » de `release.yml` l'appelle avec `-s` : sous jq 1.6, la version du conteneur de release, un `latest.json` vide passait (rc 0) et un fichier à deux documents était jugé sur le dernier. `scripts/build-latest-json.test.mjs` (28 → 36 tests) exécute le texte exact de l'étape « Publish » avec un `curl` factice : `DELETE` puis `PUT` sur un manifeste valide, sortie 1 sans aucune requête sur un refus. `check-frontend` ne saute plus `.claude/**` ni `release.yml` (`REVIEW.md`, `CLAUDE.md` et `architecture.md` à jour) ; commentaire périmé de `release.yml:104` corrigé (point 3 de #461) ; `docs/qa-update-cycle.md` note qu'un build `tauri dev` sous Linux affiche `TargetsNotFound`, attendu depuis #418. Aucune entrée de CHANGELOG (rien de visible pour l'utilisateur). Aucune migration (v1→v17).
|
||||
|
||||
**#421 mergée sur `main`** (2026-10-18, `ff9656d`, publiée dans la v0.17.2, vérifiée sous Linux et fermée le 2026-10-18) — sur desktop, le script d'initialisation de `tauri-plugin-dialog` 2.6.0 remplace `window.confirm` par une fonction asynchrone : `confirm(...)` rendait une promesse, toujours vraie, et six confirmations laissaient l'action partir avant la réponse (profil et sa base supprimés même sur « Annuler » ; suppressions de catégorie, d'ajustement, de modèle de budget et de catégorie du Bilan ; les deux confirmations du retrait de la clé de licence), dans toutes les versions publiées. `confirmAction()` (`src/services/confirmService.ts`) appelle `ask` de `@tauri-apps/plugin-dialog` 2.6.0 (ajouté à `package.json` à la version du crate, une seule clé de lock) avec une icône d'avertissement et les boutons `common.confirm` et `common.cancel` dans la langue de l'app ; un échec vaut refus et passe par `logWarn`. Les six sites l'attendent avec `await`, et `performRemoveLicense` prend une confirmation asynchrone. Garde `src/__integration__/native-dialog-guard.test.ts` (34 tests) : aucun `confirm` ni `alert` natif dans `src/` (nu, par `window`, `globalThis` ou `self`, en `?.(`, par index, déstructuré), toute confirmation attendue, aucune autre mention de `confirmAction` (alias). `architecture.md` à jour (service, ligne du plugin) ; `[Unreleased]` porte un Corrigé de plus. Suivi : #467. Aucune migration (v1→v17).
|
||||
|
||||
**v0.17.2 shippée** (2026-10-18, tag `v0.17.2` sur `f42f360` → CI release run 570, 7 artefacts + `latest.json` vérifiés par `verify-release.sh`, endpoint de l'updater en 0.17.2 avec les trois clés `linux-x86_64-deb`, `linux-x86_64-rpm`, `windows-x86_64`) — tout ce qui s'était accumulé depuis la v0.17.1 : retours d'onboarding (#424-#432), Répartition en Base (#369), clé `.rpm` de `latest.json` (#320), Node 24 dans les workflows (#376), garde `windows-sys` (#345), fiabilité des tests et de `db.ts` (#408, #409, #389, #395), menus de catégorie (#423), `rustls` 0.23.46 (#447), updater sans `zip` (#455), garde de `latest.json` sous jq 1.6 (#419), confirmations attendues (#421). Numéro de correctif choisi par Max malgré les entrées Added. `[Unreleased]` vidé au tag. Aucune migration DB nouvelle (v1→v17). Étape 10 de `/release` obligatoire (`release.yml` et `rustls` changent) : cycle Linux `.deb` v0.17.1 → v0.17.2 réussi le 2026-10-18 (Max, trace dans le corps de la release), Windows et `.rpm` pas encore déroulés.
|
||||
|
||||
## Decisions recentes
|
||||
|
||||
- 2026-09-04 : **#343 ouverte, #344 mergée — l'audit quotidien rouge depuis 17 jours, corrigé (`main` `5154c52`).** Découvert au `/warmup` : `audit.yml` échouait tous les matins depuis le **2026-08-19** (runs 375→391), dernier vert le 08-18, **aucun push sur `main` dans l'intervalle**. Une seule vulnérabilité : **RUSTSEC-2026-0258** / GHSA-q83h-524g-xf6h, `h2` 0.4.13, datée du 08-17 — DoS de sévérité faible, les frames HTTP/2 `DATA` vides sont acceptées et mises en file **sans aucune limite**, donc sur un flux non drainé la mémoire croît sans borne et la longueur peut déborder en panique. Le délai 08-17→08-19 est la propagation dans l'advisory-db. Ces 17 jours **sont** le mode d'échec que l'ADR 0018 visait à empêcher — « alarme permanente = alarme ignorée » — cette fois par le haut : le gate criait, personne ne regardait. **Atteignabilité par triple explicite**, jamais `--target all` : `h2` est sur les **deux** cibles livrées par deux chemins, `hyper`←`hyper-rustls`←`reqwest 0.13.2`←`tauri-plugin-updater` (le chemin de la mise à jour automatique) et `hyper-tls`←`reqwest 0.12.28` déclaré directement en `Cargo.toml:43` (licence, auth, cours, rétroaction — 4 modules). Correctif publié + crate atteignable = **règle 1** de `.cargo/audit.toml`, donc corrigé et non suppressé ; ni le fichier de suppressions ni le garde-fou de `check-rust.yml` ne bougent. **Le piège, et la raison d'un diff de 2 lignes** : `cargo update -p h2` donne bien 0.4.19 mais réaiguille au passage 7 pointeurs `windows-sys` (`colored`, `dirs-sys`, `errno`, `rustix`, `rustls-platform-verifier`, `tempfile`, `winapi-util`) de `0.61.2` vers 0.48/0.52/0.59 — **exactement ce que #321 avait défait délibérément le 08-15**. Laisser passer aurait régressé cette décision en silence, 3 semaines après. Le lock a donc été restauré et **seule la strophe `h2`** appliquée (version + checksum), après avoir **retracé depuis la source** — `git show v0.14.0:src-tauri/Cargo.lock`, les 7 crates comparés un à un — que `0.61.2` est bien l'état du tag, donc la seule configuration `windows-sys` dont on ait la preuve qu'elle compile sous Windows ; relayer la note de #321 n'aurait pas suffi. `cargo metadata --locked` accepte le résultat **sans le réécrire**. **`/pr-review` APPROVE et a trouvé ce que j'avais manqué** : une strophe écrite à la main ne garantit plus ni checksum ni liste de dépendances, et `h2` 0.4.19 **resserre sa borne `http` de `^1` à `^1.1`** — le genre de changement qui invalide un lock épinglé ; satisfait ici (`http 1.4.0`), checksum authentifié par `sha256sum` du `.crate`, et nuance utile qu'il pose : `cargo metadata --locked` prouve le graphe, **pas** le checksum — c'est le `Downloaded`→`Compiling` du run 392 qui le prouve. **Preuves plutôt que déductions** : contrôle sur l'ancien lock (exit 1 sur RUSTSEC-2026-0258) montrant que l'outil local reproduit le rouge de la CI ; log de la CI lu ligne à ligne parce que l'étape `cargo audit` de `check-rust` est `continue-on-error: true` et qu'un job vert n'y prouve rien ; garde-fou tracé (`Suppression guard: 2 checks, exit 0`) ; **111 Rust** ; et `workflow_dispatch` d'`audit.yml` **sur `main` après merge** (run 393, `Job succeeded`) plutôt que d'inférer le vert du run de PR. **Deux acquis au passage** : (1) le **glob `src-tauri/**` est enfin prouvé** — cette PR est le cas test isolé attendu depuis #232 (lock + 2 `.md` seulement) : `check-rust` a tourné, `check-frontend` correctement sauté par sa denylist ; le suivi peut être fermé ; (2) **#345 ouverte** — la contrainte `windows-sys 0.61.2` en est à sa 3e rencontre (#310 l'introduit, #321 la défait, #344 l'évite à la main) et ne vit dans **aucun** ADR, aucun commentaire de `Cargo.toml`, aucun garde CI : seulement 3 lignes de prose de STATE.md et un message de commit, donc le prochain `cargo update` la régressera en silence. Merge via l'API Forgejo (rebase ff, SHA inchangé) plutôt qu'en local, ce qui évite la réconciliation manuelle des PRs. Aucune migration DB (v1→v17). (ref #343, #345, PR #344)
|
||||
- 2026-08-15 : **#321 + #322 mergées ff-only (`main` `97d376b`) — deux PRs de 18 jours, deux revues du 08-14 jamais traitées, et une revue qui se corrige elle-même.** Passes de re-vérification lancées depuis le main loop (jamais en sous-agent, [[feedback-pr-review-subagent-forks]]) et séquencées plutôt que parallélisées — deux revues concurrentes partagent le même chemin de scratch et s'écrasent entre le Write et le POST (gotcha du chantier import). **Les deux passes ont retracé les claims depuis la source au lieu de relayer, et les deux ont trouvé une erreur dans la revue qu'elles re-vérifiaient.** (1) Sur #321, la revue du 08-14 lisait les 7 réaiguillages `windows-sys` comme une dérive de re-résolution et demandait un build Windows avant de tagger : **le sens est l'inverse** — #310 les avait fait *descendre* de `0.61.2` vers 0.48/0.52/0.59, cette PR les *remonte* à `0.61.2`, l'état exact du tag `v0.14.0` (`git show v0.14.0:src-tauri/Cargo.lock`, crate par crate). v0.14.0 taggée le 07-18, #310 mergée le 07-27 → le build NSIS de v0.14.0 a réellement compilé contre `0.61.2`. **La configuration non prouvée sous Windows était celle de `main`** ; la PR réduit le risque. (2) Sur #322, la revue du 08-14 annonçait « trois » déclencheurs et mappait `UpdateCard.tsx:201` à la page d'erreur — faux dans les deux sens : `:201` est le retry de l'état `error` de la carte, et `ErrorPage.tsx:82` est un **4e** site distinct qui **détecte seulement** (`handleCheckUpdate` s'arrête à `setUpdateStatus("available")`). Appliquer « écrire trois » aurait échangé une inexactitude contre une autre. **Le blocage de #322 était réel et non traité** : la checklist demandait de cocher `readyToInstall` **avant** l'invite d'authentification, alors que `download_and_install` fait `download(...).await?` puis `install(bytes)` dans le même appel (`updater.rs:723-729`), que `Finished` est un no-op côté UI (`useUpdater.ts:119-121`) et que `READY_TO_INSTALL` n'est dispatché qu'après le retour de `dpkg -i` — l'invite pkexec s'ouvre donc **par-dessus « Téléchargement en cours… »**. Conséquence exacte de l'ordre des cases : sans agent polkit l'app se fige en `downloading`, le testeur guette un blocage après `readyToInstall`, et rapporte « le téléchargement bloque » au lieu de « pas d'agent polkit » — **la page produisait la fausse trace qu'elle existe pour éliminer**. Corrigé en `97d376b` avec 3 mineurs (les 4 déclencheurs, les préfixes `/api/v1/packages/` (DELETE) vs `/api/packages/` (PUT) que `release.yml:217-219` commente déjà, ordre du changelog `SKILL.md`) + titre de PR (« step 9 » → « as step 10 »). Merge : rebase des 2 branches (15 commits de retard) en pile linéaire, **preuve que le rebase préserve `.cargo/`/`.forgejo/`/`Cargo.lock` au byte près** vs le commit validé par la CI run 338, puis CI re-jouée quand même (runs 370/371 verts, garde-fou **exécuté et tracé**, `cargo audit` 0 vulnérabilité / 22 warnings autorisés) et `cargo test` **111 passed** — le « 106 » de la description de #321 était périmé, le chantier import ayant ajouté 5 tests. Issues #312/#313/#315 auto-fermées par `Resolves`, PRs fermées à la main (Forgejo ne détecte pas un merge local comme *merged*), branches supprimées. **#314 trouvée caduque au passage** : `audit.yml` tire bien tous les jours à 06:00 UTC — 19 runs `schedule` verts du 07-28 au 08-15 — et son `workflow_dispatch` de référence datait du 07-27 à 23:56Z, soit ~6 h avant le premier tir programmé possible. L'issue a été écrite avant que le scheduler ait pu s'exprimer, pas après l'avoir vu échouer. (ref #312, #313, #314, #315, PRs #321/#322)
|
||||
- 2026-08-14 : **Import CSV — chantier complet livré (10 issues, migration v17)**. Cycle intégral en une session : revue d'implémentation → `/spec` → `/review-spec` → `/plan-run` → `/autopilot` → `/pr-review` ×10 → merge ff-only. **Le diagnostic a tenu, mais trois choses ont été trouvées en cours de route que personne n'avait vues.** (1) Au re-ancrage Phase 3b : `dataExportService` ne sérialise ni `import_sources` ni `import_config_templates` et fait `DELETE FROM import_sources` à la restauration — **restaurer une sauvegarde détruisait toutes les configurations d'import**, 3e voie de perte du format, indépendante du bug racine (→ #331, + `withTransaction` que le service n'avait nulle part). (2) Un worker a **mesuré** que l'exemple « Solde 2024 » de l'issue n'est pas défectueux (`parseFloat` s'arrête sur le `S`) et a ajouté la fixture qui l'est vraiment. (3) Un autre a mesuré que chez RBC `CAD$` et un `Cheque Number` presque vide **sont** sparse-complementary : le générique les apparie et une ligne de chèque s'importe à −234,05 au lieu de −6,95 — d'où l'override par signature. **Correction de la revue initiale** : `parseFrenchAmount("50,00-")` ne rend pas 50 mais **5000** — `"100,00 CAD"` → 10000, `"1 234,56 CR"` → 123456 : erreur de **facteur 100** qui passait `isNaN` et comptait donc la ligne comme VALIDE. Le ×100 atteignait aussi l'import de titres #245. Durcissement global assumé : un relevé à suffixe de devise produit désormais des **lignes en erreur** plutôt que des valeurs fausses. **`/pr-review` a bloqué 3 des 10 PRs, tous fondés** : (a) #336 — la règle débit/crédit testait `isNaN(debit) && isNaN(credit)`, donc une cellule illisible à côté du remplissage `0,00` calculait 0 − 0 = 0 et importait en silence, **le bug même que la PR fermait, une cellule plus loin** (rejoué sur sa propre fixture : 6 transactions à 0,00 $, 0 erreur) ; (b) #337 — `detectDescriptionColumn` retournait la colonne préférée **sans veto par les données**, et le mot-clé `transaction` capturait la colonne DEBIT/CREDIT de Tangerine, tuant la catégorisation ; (c) #340 — **régression réelle** : la signature Desjardins est 4 libellés génériques appariés par sous-ensemble, et une signature court-circuitait le scan sparse-complementary → un fichier `Date;Description;Débit;Crédit;Montant;Solde` lu correctement AVANT devenait `positive_expense` APRÈS, chaque dépôt en dépense. Corrigés en 3 commits sur la tête de pile (`484c4be`, `ef7de3c`, `37b832e`), **chacun vérifié par mutation** — et cette vérification a révélé qu'un de mes propres tests **passait la mutation** (le fix sur les signatures génériques empêchait la branche gardée d'être atteinte) : il a fallu un fichier reconnu par une signature *légitime* pour l'exercer. Leçon : un garde qui ne peut pas échouer ne garde rien, y compris quand c'est soi qui l'écrit. Incident run : le worker #331 tué par une limite de session pendant sa validation — travail récupéré depuis son worktree et revalidé de zéro (le rationale de sa PR est une reconstruction, signalé comme tel). Gotcha parallélisation : deux agents de revue partageaient `scratchpad/review.md`, l'un a écrasé l'autre entre Write et POST → **donner un chemin de scratch unique par agent**. Suivis ouverts : refus du format à indicateur borné à `amountCol ± 1` ; dérives de comptes préexistantes dans `CLAUDE.md`/`architecture.md` (mesurées et documentées, non corrigées). (ref #323-#332, PRs #333-#342)
|
||||
- 2026-07-27 : **#310 + #311 mergées — `cargo audit` 9→0, `npm audit` 3→2 acceptées** (`main` `a14258b`, ff-only ; PRs #316 → #318 stackées, CI verte des deux côtés). Parties d'un `/analyse-vulnerabilite` : rapport Défenseur **écarté comme source** (VPS injoignable — SSH Tailscale en attente d'auth navigateur, aucun lien imprimé ; rapport local du 2026-05-06, 82 jours) → vérité live = `cargo audit 0.22.2` + `npm audit` sur `main`, advisory-db `0bfde9d6`. **Le triage d'origine de #310 révisé sur un point** : `quick-xml` (2× 7.5 high) n'est **compilé sur aucune des deux cibles livrées** — `cargo tree -i` vide sur `x86_64-pc-windows-msvc` **et** `x86_64-unknown-linux-gnu`, présent seulement sur `x86_64-apple-darwin` via `plist`←`tauri` (dép. conditionnelle Apple). Il n'y avait donc **rien à attendre de Tauri**, contrairement au « probablement bloqué en amont » du corps. Méthode retenue : tracer **par triple explicite**, jamais `--target all` (qui répond « présent » pour des deps Apple jamais compilées ici) — mémoire [[reference-cargo-audit-reachability-par-cible]]. 6 advisories atteignables corrigées par `cargo update -p rustls-webpki -p tar` (0.103.9→**0.103.13** couvrant les 4, 0.4.44→**0.4.46**) sans toucher `Cargo.toml` ; dérive de lock expliquée et bornée (7 pointeurs `windows-sys` repointés vers des versions **déjà présentes**, 666 paquets avant/après, deps Windows-only → no-op sur Linux ; `--precise` donne le même résultat, c'est la re-résolution de cargo 1.94.1). Les 3 restantes → `.cargo/audit.toml` versionné, lu par les deux workflows sans qu'aucun ne le sache. **Le plan checker a levé un MAJOR fondé** : une liste de suppressions sans déclencheur de retrait **inverse** le problème de l'alarme (l'audit resterait vert si `quick-xml` redevenait atteignable) — d'où 3 règles en **ADR 0018** : admission sur preuve par cible livrée ou absence de correctif ; clé **par ID d'advisory jamais par crate** (une nouvelle advisory sur le même crate repasse au rouge) ; **garde-fou bloquant** dans `check-rust.yml`, placé après `cargo check` (index chaud) avec `--locked`, testant le **code de sortie séparément de la sortie** (un crate absent et un `cargo tree` en panne impriment tous deux du vide) + **canari** `tar`. Le pendant — suppression devenue *inutile* — est #312. **Leçon du 1er run CI** : le garde était **muet en cas de succès**, donc indiscernable dans les logs d'une étape non exécutée — exactement le mode d'échec silencieux que la PR combat (même famille que le « glob `src-tauri/**` non prouvé » de #232) → il logge maintenant chaque vérification (`Suppression guard: 4 checks, exit 0`, visible run 336). Preuve que `.cargo/audit.toml` est bien lu en conteneur : le `workflow_dispatch` de `audit.yml` sur la branche est **vert**, alors que le bump seul n'aurait éliminé que 6 des 9. **npm** (#311) : `postcss` 8.5.13→**8.5.23 sans override** — `vite` déclare `^8.5.3`, seul le lock était périmé (≠ cas #241 où le parent pinnait) ; `nanoid` suit dans sa borne ; **CSS émis identique octet pour octet** (postcss *est* le pipeline CSS, un build vert n'aurait prouvé que la compilation) ; `react-router` **accepté** — advisory mode **RSC**, or `App.tsx:109` monte un `BrowserRouter` client-only, et `react-router-dom` est **figé à 7.18.1** (v8 a fusionné le paquet dans `react-router`) donc le « fix » npm est un **downgrade** en 7.11.0 → sortir de la plage = migration, pas bump (#317, titre portant `react-router` pour la dédup par sous-chaîne de `/analyse-vulnerabilite`). Asymétrie à connaître : **aucun `npm audit` en CI**, donc rien ne rougit côté front — écrit dans `architecture.md` + `CLAUDE.md` pour que les 2 high permanents ne se lisent pas comme une régression. Gotchas Forgejo relevés : logs de job **uniquement via l'URL web** (l'API v1 répond 404 partout) et `head_branch` = `#<PR>` sur les runs `pull_request` → [[reference-forgejo-ci-logs-et-head-branch]]. 106 Rust + 871 vitest + builds verts. Aucune migration DB (v1→v16). (ref #310, #311, PRs #316/#318, #312-#315, #317)
|
||||
- 2026-07-27 : **#232 mergée — CI split, caches morts retirés, `cargo-audit` pré-buildé** (`main` `7779f7d`, PR #309 rebase, milestone `spec-ci-build-optimization` **3/4**). `/analyze` a confirmé la prémisse mais **révisé le cadrage sur 3 points mesurés** : (1) les 2 jobs ne tournent **pas en parallèle** — runner à capacité 1, `frontend` démarre à la seconde où `rust` finit sur tous les runs de l'historique → chaque PR payait rust+frontend ≈ 24,5 min ; (2) **3 des 40 derniers commits first-parent** touchent `src-tauri/`, dont 2 `chore: release` (push sur `main`, ne déclenche pas la CI) → le path-filter est le **gain dominant**, pas un effet de bord ; (3) le risque « required check skippé » est **nul** (`enable_status_check: false` sur la protection de `main`, vérifié API). Chrono re-mesuré du run 326 : **12m15s de gaspillage sur 21m44** — save `target/` 6m11 + save registry 43s (`reserveCache failed`, cause #234) + `cargo install cargo-audit` 4m41 + restores ~40s ; **nouveau vs baseline #231** : le restore **timeout aussi** désormais (`getCacheEntry failed`), le 30 juin n'avait qu'un MISS propre. Résultat : **rust 21m44 → 8m55** (−59 %), **frontend 2m28 → 1m43**, **PR frontend-only 24m12 → 1m43** (−93 %). **Trou de couverture fermé** : `branches: [main]` ne matchait pas une PR stackée → **#305-#308 (pile feature-gating) n'ont eu aucune CI**, seules #303/#304 (base `main`) ont tourné ; plus aucun filtre `branches:`. 2 écarts assumés vs le corps d'origine, tranchés sur mesure : cache npm retiré aussi (23s/run) et **denylist** pour le frontend (job à 2,5 min → le faire tourner pour rien est bon marché, ne PAS le faire tourner silencieusement ne l'est pas ; le job cher garde un allowlist strict). `/pr-review` **APPROVE** — a récupéré les logs CI plutôt que juger le diff, et levé que l'override `PATH` job-level aurait pu masquer le binaire d'`install-action` (vérifié : audit bien exécuté) ; 2 des 6 suggestions appliquées (`.claude/**` au denylist, ref `check.yml` périmée dans le skill `release`). **Skip prouvé empiriquement** : le 2e push (ni `src-tauri/**` ni `check-rust.yml`) n'a **pas** déclenché `check-rust`. **Reste non prouvé — le glob `src-tauri/**` lui-même** (seule l'entrée chemin-exact a matché) : mode d'échec **silencieux** sur la PR Rust (1/40, ni `cargo check` ni `cargo test`) → #310 (`cargo update` touchant `Cargo.lock` seul) sera le test isolé, à surveiller. Le retrait du `|| true` a **découvert 9 advisories RUSTSEC réelles** (préexistantes, seulement masquées) → **#310** ouverte : `rustls-webpki` ×4 + `tar` ×2 atteignables via `tauri-plugin-updater`/`reqwest` (correctifs **patch**), `quick-xml` ×2 bloqué en amont par `plist`←`tauri` (bump majeur), `rsa` sans correctif publié mais seul parent `sqlx-mysql` — absent de tout arbre de compilation (`cargo tree -i rsa --target all` vide, projet SQLite) donc vraisemblablement **inatteignable**. Conséquence : `audit.yml` (quotidien, bloquant par choix) sera **rouge dès son 1er run** tant que #310 n'est pas traitée — alarme permanente = alarme ignorée, donc à lander tôt. Aucune migration DB (v1→v16). (ref #232, PR #309, #310)
|
||||
- 2026-07-21 : **Milestone `planned-2026-07-19-feature-gating` livrée 6/6 et fermée — le gating par tier est sur `main`** ([Unreleased], candidate v0.15.0). Reprise du run `/autopilot` interrompu le 19 au soir (mort après la PR #303 ; worker #298 tué sans commit — 2 worktrees + branche vide nettoyés). Séquence : `/pr-review` #303 **REQUEST_CHANGES** (le retry backoff CWE-703 s'armait aussi sur un échec de `submitKey` → l'auto-refresh effaçait le message « clé invalide » ~1 s après ; et `status: "error"` persistant aurait laissé `ready=false` à jamais pour les futurs RequireFeature) → fix `b9e13b5` : validation de clé **orthogonale** au lifecycle de chargement (`validationError` dédié, `status` intouché, reducer exporté + 6 tests) → **APPROVE** → merge API. Relance `/autopilot` en session : **5 workers séquentiels en pile linéaire** (#302 dépend de tout ; CHANGELOG centralisé dans #302 → zéro conflit inter-maillons) → PRs #304-#308, 0 needs-clarification, 0 blocked ([rapport](reports/DAILY-REPORT-2026-07-20.md)). `/pr-review` **×5 parallèles → APPROVE ×5** ; seule retouche user-facing : coquille FR `docs.editions` (« tout la » → « tout de la », `6de9617`). Merge : tip cumulé validé (871 vitest + build + cargo 106) puis **ff-only** → push `main` **refusé : branche protégée** (whitelist `maximus`, règle du 2026-03-07, jamais rencontrée avant) — cause réelle : `~/.git-credentials` porte 3 identités Forgejo et `defenseur-auto-bot` en 1re position **shadow** `maximus` (git prend la 1re entrée du host ; les pushes de branches passaient, seule `main` whitelistée refusait). Fix scopé : `git remote set-url origin https://maximus@…` (ne PAS réordonner le fichier partagé — les agents defenseurs s'authentifient par lui). Push OK → 5 issues auto-fermées (`Resolves #N`), 5 PRs fermées + commentées, milestone 6/6 fermée, branches supprimées (+ purge de 7 branches `worktree-agent-*` du harness ; le « résidu `origin/issue-259` » signalé au warmup était un tracking ref périmé faute de `fetch --prune` — la branche remote avait bien été supprimée le 19). Décisions workers notables : tuiles `ProfileSelectionPage` **non** verrouillées (page atteinte seulement sans profil actif résoluble → un verrou heuristique risquerait un soft-lock hors de tous les profils) ; dev-override en **tête** de résolution (sinon un compte Premium actif masquerait `SR_DEV_EDITION`) ; `#[serde(default)]` préexistant sur `features` = les licences Base déjà émises sans le champ ne régressent pas. Gotcha process : un fork `/pr-review` lancé pendant que la CI est `pending` se termine « en attente du monitor CI » **sans jamais poster** (rien ne survit au fork) → monitorer la CI depuis le main loop, relancer le skill après le vert. Post-merge non bloquant : extraction `LockBadge`/`UpsellPanel` si une 3e surface apparaît ; label PIN « (annuler) » (bug cosmétique préexistant) ; job CI ponctuel `--features dev-override`. ADR 0017 Accepted. Aucune migration DB (v1→v16). (ref planned-2026-07-19-feature-gating, #297-#302, PRs #303-#308)
|
||||
- 2026-07-19 : **Chantier gating par tier planifié via `/spec` → milestone `spec-feature-gating` (#297-#302)**. Origine : réflexion monétisation de Max (« changer le périmètre des abonnements »). Matrice tranchée : **Free** = Dashboard/Import/Transactions/Catégories/**rapport Tendance**/Export chiffré/Changelog (mono-profil) ; **Base** = + les 4 autres rapports/Budget/multi-profils/auto-update ; **Premium** = + Bilan complet (patrimoine + cours). Admin (Max) = licence Premium auto-émise (pas de 4e édition). Décisions clés : upsell **verrouillé** (pas masqué) ; durcissement **sec non destructif** (blocage d'accès, données conservées, récupérables par upgrade) ; entitlements **statique + override `features[]`** ; **enforcement UI-only** (soft-paywall GPL assumé, seul le gate cours reste dur/server-enforced) ; **#271 absorbé** (auto-update Base+, fermé superseded). Re-ancrage Phase 3b : socle déjà là (3 éditions Ed25519, `current_edition`, `check_entitlement`) mais `is_feature_allowed` ignore `features[]` + `useLicense` per-appel → **LicenseProvider** requis pour un `useEntitlement` sync ; rapports = routes distinctes (gate par route) ; #271 = 1 ligne. 6 issues (socle→garde UI→routes/sidebar→multi-profils→#271 Rust→docs), specs force-add (gitignorées, précédent #295), aucune migration DB. **`/review-spec` 3 experts → verdict 🟡, tout corrigé dans le plan** : 2 🔴 (nav `reports` gaté à tort alors que hub Free ; `advanced-reports` Rust mort contredit `reports-advanced`) + 6 🟡 (override `features[]` **fail-closed en Free** CWE-863 : une clé copiée downgrade free mais expose ses features signées ; `SR_DEV_EDITION` derrière une Cargo feature `dev-override` pas `debug_assertions` CWE-489 ; LicenseProvider récup. d'erreur CWE-703 ; `useEntitlement` → `{allowed,ready}` anti-flash ; `useIsPremium.test` à migrer ; gate création profil dans `ProfileFormModal`, point unique). Ajustements tranché **Base** par Max. **Re-homée `planned-2026-07-19-feature-gating`** (`/plan-run` Step 0 : bodies rendus auto-suffisants ; résiduel drainé — CTA « Obtenir » désactivé + « bientôt », dev-override gardé). Prête `/autopilot`. (ref planned-2026-07-19-feature-gating, #297-#302)
|
||||
- 2026-07-18 : **Epic #260 « rapports uniformes » fermée + #259 livrée en PR #296 (review)**. #260 fermée après `/analyze` : **4/5 rapports pleinement conformes** (income-statement + filtres partagés + collapse par-profil) ; les 3 écarts restants vs le texte de l'epic sont des **décisions de conception assumées**, entérinés par Max — (1) lignes vides budget non masquées (spec décision 6 : grille = surface d'édition) ; (2) collapse budget replié « comme partout » (#289/ADR 0016 inverse le « sauf budget » de l'epic) ; (3) dashboard convergé sur le modèle Cartes plutôt qu'une table income-statement hiérarchique (décision 5). **Correction STATE** : les entrées 07-08/07-11 « reste de #260 = #259 » sont fausses — #259 est une migration de taxonomie sans lien avec l'epic (recadrée 07-12), et les vraies déviations #260 n'y étaient pas tracées. **#259** (fusion des catégories custom) livrée en PR #296 (`issue-259-merge-custom-categories`) : bloc préservé de `StepSimulate` rendu en `MappingRow`, reducer `RESOLVE_ROW` résout rows+preserved (`unresolved` compté sur seed only → ne bloque pas « Suivant »), writer via helper `isResolvedTarget` (fourre-tout créé seulement s'il reste une custom non fusionnée ; customs fusionnées désactivées au lieu d'être re-parentées). Plan-check pass (6 MINOR, 3 intégrés), **836 vitest** + build tsc/vite propres, régression parent/enfant custom couverte, aucune migration DB. `/pr-review` **APPROVE**, mergée (rebase) le 2026-07-19 → `main` `2314a64`, #259 fermée, branche supprimée. (ref #260, #259 PR #296)
|
||||
- 2026-07-18 : **Milestone collapse multi-niveaux (#288-291) livrée → v0.14.0**. Cycle complet en une session : `/plan-run` (spec 2 fichiers, 8 décisions drainées) → `/review-spec` (3 experts, **verdict 🔴**) → refonte v2 → `/autopilot` (4 workers) → `/pr-review` ×4 → ff-merge → release. **Le point clé** : la revue a tué l'algo v1. Il suivait un curseur de profondeur supposant un **ordre DFS** ; or la grille Budget (`useBudget.ts:361`) trie par **niveau** — les 3 experts l'ont trouvé indépendamment. Refonte v2 : **visibilité par remontée de `parent_id`** (une ligne visible ssi tous ses ancêtres dépliés), order-independant → résout d'un coup l'ordre budget, le tri par type qui sépare parent/enfant, la feuille « (direct) » qui partage la clé du parent, et la contrainte `visible()`-avant-`reorderRows`. **Pile linéaire forcée** (B/C/D dépendent tous du nouveau hook de A → pas de wave parallèle) : PRs #292-#295, chacune basée sur la précédente. `/pr-review` **APPROVE ×3 + 1 REQUEST_CHANGES** (#295 : ligne `- Spec:` de l'ADR 0016 pointant des specs gitignorées → 404 ; corrigé par **force-add des specs à la racine**, précédent repo `spec-refonte-rapports.md` ; au passage la revue s'est trompée en disant que 0015 n'avait pas de ligne Spec — elle en a une, cassée pareil, bug pré-existant signalé). **ff-merge** de la pile (4 issues auto-fermées via `Resolves #N`, milestone 4/4, 4 branches supprimées) → **v0.14.0** taggée. **Persistance migrée `localStorage` → `user_preferences`** (base du profil) : `deleteProfile` ne purge aucun `localStorage` → une clé par-profil y serait un résidu survivant à la suppression, révélant les catégories explorées d'un profil PIN-protégé (exigence privacy-first, **ADR 0016**, frontière tracée : état UI par-profil → DB profil, état UI machine → localStorage). Le hook gagne `defaultExpanded` + `storageKey` nullable, qui **unifie aussi les 2 arbres de catégories** (#290 : `CategoryTree` déplié-par-défaut, guide replié ; corrige le bug `allExpanded = size>0` du guide ; worker a trouvé un **4e consommateur** `StepDiscover` non listé au plan, migré). 828 vitest, aucune migration DB. Écarts protocole tracés au [rapport](reports/DAILY-REPORT-2026-07-15.md) : workers auto-validés **sans forker `/pr-review`** (évite le double-post [[feedback-pr-review-subagent-forks]]) ; #294 sans test (refactor de rendu, non testable sans jsdom) → vérif runtime déléguée à la revue. Reste `spec-ci-build-optimization` (2/4) + `spec-paiements` (#270/#271) + #259. (ref #288-291, PRs #292-#295)
|
||||
- 2026-07-12 : **#259 recadrée — l'issue décrivait un flux qui n'existe pas** (rectifie les entrées des 2026-07-05 / 07-11 qui la classaient « mapping manuel compte sans similaire auto » et « reliquat de l'epic #260 » : les deux sont faux). Le corps d'origine, rédigé à chaud le 2026-07-05 pendant le run v0.12.0, avait **transposé par analogie** le signalement de Max vers le module Bilan sans ouvrir le fichier : il décrivait une auto-association des comptes standards aux comptes existants du profil dans `StarterAccountsModal`, avec « case désactivée quand aucun similaire n'est auto-identifié ». **Aucune notion de mapping n'existe dans ce flux** — la case est un simple « créer ce compte : oui/non », désactivée **quand une collision EST détectée** (`StarterAccountsModal.tsx:160`), soit la polarité inverse ; le commentaire `l.8` cité (« the matching checkbox ») désignait « la case **correspondante** », pas « la case de matching ». **Le vrai sujet** (confirmé par Max) est la **migration des catégories** : `computeMigrationPlan` range les catégories en 2 seaux et un seul est éditable — `plan.rows` (seed) passe par le moteur d'appariement + type-ahead par ligne (#246/#252), tandis que `plan.preserved` (catégories **custom**) est poussé avec `v1TargetId: null` **sans même être soumis au moteur d'appariement**, rendu en `<li>` texte brut (`StepSimulate.tsx:202-216`, aucun picker) et déversé d'office par le writer sous le fourre-tout « Catégories personnalisées (migration) ». Sémantique tranchée avec Max : **fusion** (choisir une feuille standard réassigne tx/budgets/mots-clés/fournisseurs et fait disparaître la custom ; ne rien choisir = comportement actuel, et ne doit **pas** bloquer le bouton « Suivant »). Re-parentage écarté. Travail = câblage sur 3 couches (UI `StepSimulate` → `MappingRow` réutilisable tel quel ; reducer `RESOLVE_ROW` qui ne voit que `plan.rows` ; 4 retouches du writer) — la machinerie de fusion est **déjà générique** (`buildMappingFromRows` filtre les cibles nulles, étapes 3-7 bouclent sur la Map). Complexité Medium. Piège à couvrir : fusionner une custom **parente ayant des enfants custom** (liste `preserved` plate → l'enfant non résolu retombe au fourre-tout, pas d'orphelin, mais aucun test ne le garantit). Leçon process : une issue rédigée par analogie en fin de run, sans lecture du code, peut inverser la prémisse **et** se tromper de module — le `/analyze` l'a rattrapée 6 jours plus tard. (ref #259)
|
||||
- 2026-10-18 : **v0.17.2 taggée et publiée (`f42f360`, run 570 vert), sur demande de Max (« /release 0.17.2 »).** Pré-vol sur le tip `e0c0f9e` : build, 2297 vitest, `cargo check`, 267 tests Rust, `.claude/worktrees/` vide. Bump des 5 manifestes, 2 changelogs et de la ligne de `CLAUDE.md`, `Cargo.lock` édité à la main et accepté par `cargo metadata --locked`, garde `windows-sys` rejoué sur le lock édité (9 contrôles, exit 0). `[Unreleased]` portait des Added (la convention donnerait 0.18.0) : question posée, Max a confirmé 0.17.2. Gating changé par #369 (Répartition en Base) : clés Premium des deux testeurs déjà émises, rien à émettre ; le tableau comparatif du site était déjà en avance, commentaire 25050 sur la-compagnie-maximus#180. Tag annoté poussé seul (jamais `--tags`). Étape 9 : `verify-release.sh` en `VERDICT : OK` (7 artefacts, trois clés, extensions, `file:` des signatures, version) ; journal du run : Node `v24.22.0` (#376 prouvé), « Generate latest.json » un paquet par type, garde jq passée, `Delete HTTP 204` puis `Upload HTTP 201` ; endpoint de `tauri.conf.json` en 0.17.2 à trois clés. Restent à Max : étape 10 (cycle réel v0.17.1 → v0.17.2, Linux et Windows, clé Base+), vérifications dans l'app (#421, #369, retours v0.17.1), cycle `.rpm` si une machine est disponible. Aucune migration DB (v1→v17). (ref v0.17.2, #419, #421, #369, #376, #320, la-compagnie-maximus#180)
|
||||
- 2026-10-18 : **#421 mergée — `/fix-issue` (contrôle du plan passé du premier coup) → `/pr-review` APPROVE de niveau haut → un commit de suivi en tête → merge ff par l'API (PR #465, `main` `ff9656d`) : les confirmations attendent la réponse de l'utilisateur ; issue gardée ouverte (`Refs`, `status:blocked`) jusqu'à la vérification manuelle de Max.** Cause : le script d'initialisation desktop de `tauri-plugin-dialog` 2.6.0 remplace `window.confirm` par une fonction asynchrone, donc chaque garde `if (!confirm(...)) return;` testait une promesse, toujours vraie ; défaut présent dans toutes les versions publiées, le plus grave étant la suppression d'un profil secondaire et de sa base sur « Annuler ». Correctif : `confirmAction()` sur `ask` du plugin, un échec valant refus ; `@tauri-apps/plugin-dialog` ajouté à la version du crate, d'où le niveau haut (dépendance ajoutée). `tsc` ne relève pas `!promise` (mutation M1 verte sous `tsc`), d'où la garde `native-dialog-guard.test.ts`. Preuves : reproduction par six tests rouges de `useLicenseMachines.test.ts` avec une confirmation asynchrone ; `ProfileFormModal.delete.test.ts` sur le harnais `hookHarness` ; mutations de l'auteur rouges à 8 sur 8 ; revue (commentaire 24953) avec 8 mutations du relecteur sur 9 rouges, la survivante étant un alias (`const c = confirmAction`). Suggestion 1 appliquée après la revue (`ff9656d`, tests seuls, non relu, nommé dans le commentaire 25025) : l'alias et trois formes natives (`window["confirm"]`, `confirm?.(`, `const { confirm } = window`) sont désormais rouges. Rebase sur `5222329` (PR #450, mergée entre-temps, `SKILL.md` seul) ; `check-frontend` vert sur `ff9656d`, 2297 tests (2256 avant #421). Suggestion 2 → #467 (`status:triage`). Quatrième PR mesurée du pilote #458. Les deux prérequis du tag de la v0.17.2, #419 et #421, sont mergés. Aucune migration (v1→v17). (ref #421, PR #465, #467, #458)
|
||||
- 2026-10-18 : **#419 mergée — `/fix-issue` (contrôle du plan refusé deux fois, chaque fois sur un point réel) → `/pr-review` APPROVE de niveau haut → merge ff par l'API (PR #464, `main` `c859405`) : un `latest.json` vide ou à plusieurs documents ne passe plus la garde d'avant le `DELETE` du registre, et l'étape « Publish » est testée telle quelle.** Le filtre `scripts/check-latest-json.jq` exige exactement un document, appelé avec `-s` par `release.yml` ; défaut reproduit avant le correctif avec le binaire statique officiel de jq 1.6 (celui d'`ubuntu:22.04`) : fichier vide accepté en rc 0, refusé en rc 4 par jq 1.7. `build-latest-json.test.mjs` (28 → 36 tests) extrait le `run:` de l'étape « Publish », identique à l'octet à une lecture PyYAML (46 lignes), et le lance sous `bash -e -o pipefail` avec un `curl` factice en tête du `PATH` ; une garde vérifie que ce texte ne contient aucune expression de workflow, que le runner substituerait même dans un commentaire sans que le test le voie. Refus du plan check : `REVIEW.md` non mis à jour alors que le déclencheur de `check-frontend` changeait, puis une expression de workflow littérale dans un commentaire du `run:`, que le runner aurait évaluée ; troisième passage : pass. **Décision de Max** : `check-frontend` ne saute plus `.claude/**` ni `release.yml`, puisque le test lit `verify-release.sh` et exécute l'étape « Publish » (environ 2,5 min de runner de plus sur ces PR) ; `REVIEW.md`, `CLAUDE.md` et `architecture.md` suivent. Aussi : commentaire périmé de `release.yml:104` (point 3 de #461, commentaire 24939, points 1 et 2 toujours ouverts), ligne `TargetsNotFound` de `tauri dev` sous Linux dans `docs/qa-update-cycle.md`, skill `/release` à jour. Hors dépôt : `la-compagnie-maximus/docs/forgejo-ops.md`, commit `9a8e3f5` (`/actions/tasks` ne lit `limit` qu'avec `page`, remesuré le 2026-10-18 sur 654 tâches ; cas du run 513, que `cancel-in-progress` n'a pas annulé). Preuves : cinq mutations de l'auteur, toutes rouges sous jq 1.6 et 1.7 ; revue de niveau haut (commentaire 24912), six mutations du relecteur toutes rouges, passe sécurité sans constat ; `check-frontend` run 565 vert sous jq 1.6 (134 fichiers, 2256 tests) ; #419 fermée par le merge (commentaire 24941). Troisième PR mesurée du pilote #458. **Reste à prouver** (suggestion 1 de la revue) : aucune PR ne touchant que `.claude/**` ou `release.yml` n'a encore montré que `check-frontend` se déclenche, la PR #464 touchant aussi `scripts/` ; la première de ce type en fera la preuve. Reste avant le tag de la v0.17.2 : #421. Aucune migration (v1→v17). (ref #419, PR #464, #461, #458)
|
||||
|
||||
- 2026-10-18 : **#455 mergée — `/fix-issue` → `/pr-review` de niveau haut REQUEST_CHANGES, corrigé dans le texte → merge ff par l'API sans second tour (PR #459, `main` `c5efc38`) : `tauri-plugin-updater` compilé sans sa feature `zip`, `audit.yml` de nouveau vert. #457 fermée : les skills de revue lisent `REVIEW.md`.** `zip` 4.6.1, compilé pour Windows seulement par la feature par défaut `zip` de l'updater, n'est corrigé qu'en 9.0.0, hors de portée de `tauri-plugin-updater` 2.14.0 (`zip ^4`) : l'updater déclaré en `default-features = false` avec `rustls-tls` sort `zip`, `arbitrary` et `derive_arbitrary` du lock (666 → 663) et avec eux RUSTSEC-2026-0367 à -0369, sans entrée dans `.cargo/audit.toml` (règle 1 de l'ADR 0018). `tar` quittant le graphe Linux, le canari de la garde des suppressions passe à `webkit2gtk`, conditionnel à Linux comme l'était `tar` (le contrôle du plan a écarté `tauri`, inconditionnel, que proposait l'analyse) ; preuve Windows par `cargo xwin check`, témoin sur `main` (`zip` compilé) puis branche (sans `zip`). Première PR mesurée du pilote #458, de niveau haut (fixé par `src-tauri/Cargo.toml`, `src-tauri/Cargo.lock`, l'étape de verdict de `check-rust.yml` et le skill `/release`) : la revue (commentaire 24757) rejoue le correctif (quatre mutations du canari, `cargo xwin check`, passe sécurité sans constat de §1 à §4) et bloque sur une fausse preuve du corps de la PR : le cycle de mise à jour de la prochaine release part de la v0.17.1, dont l'updater a encore `zip`, il n'exécute donc pas le chemin d'installation sans `zip`. Corrigé sans commit dans le corps de la PR et dans celui de #455 (commentaire 24806) ; **décision de Max** : merger sans second tour. Preuves : rebase sur `main`, les neuf fichiers de la PR identiques à l'octet à la tête relue `85b7ef9` ; `check-rust` run 563 vert sur `c5efc38` (canari `webkit2gtk`, gardes 2/2 et 9/9, 267 tests, audit à 663 crates, lock non réécrit) ; après le merge, `workflow_dispatch` d'`audit.yml` : run 564 vert (cargo-audit 0.22.2, 1338 avis, 663 crates, aucune vulnérabilité, aucun des trois IDs `zip` ; 18 avertissements autorisés, les 12 d'avant et six `unmaintained` parus le 2026-10-18, RUSTSEC-2026-0370 à -0372) ; PR en `Refs #455`, d'où une fermeture à la main (commentaire 24830). #457 (skills globaux, hors git, aucune PR) : `pr-review` lit `REVIEW.md` sur la branche par défaut (Context), calcule le niveau au Step 0bis et l'annonce en première ligne du verdict ; profondeur par niveau (bas = lecture du diff ; haut = mutation obligatoire, passe sécurité bloquante faite dans le fork, car un fork ne lance pas d'agent et `security-scan` n'est défini qu'au niveau du monorepo, APPROVE avec « Max doit lire avant le merge. ») ; savoir propre à simpl-resultat retiré du skill ; `sprint` 4b renvoie à `/pr-review` au lieu d'une copie de checklist (la copie avait perdu la section Tests) ; `DAILY-REPORT` d'`autopilot` avec une section « Max doit lire » et `prs_high_level` ; `fix-issue` 6.5 prend ses commandes dans `REVIEW.md` ; une entrée de CHANGELOG par skill. Preuve : la revue de la PR #459 ouvre sur la ligne de niveau (commentaire 24757) ; fermeture au commentaire 24764. Pilote #458 en `status:in-progress`, relevé ouvert (commentaire 24819), PR 1 = #459. **Acquis** : le cycle réel de mise à jour d'une release exécute l'updater de la version précédente ; seul le `check()` final passe par le nouveau. Suivi #463 (l'étape 10 de `/release` et `docs/qa-update-cycle.md` rattachent le cycle à la release qui livre le changement, alors qu'un changement de l'updater ne se prouve qu'au cycle N → N+1). Aucune migration (v1→v17). (ref #455, PR #459, #457, #458, #463)
|
||||
- 2026-10-18 : **#456 mergée — `/fix-issue` (contrôle du plan refusé trois fois, chaque fois sur des points réels, derniers points appliqués, poursuite décidée par Max) → deux `/pr-review` APPROVE, le second après application des suggestions du premier → merge ff par l'API (PR #460, `main` `24da72b`) : `REVIEW.md` à la racine.** Calibration sur les PR mergées de #344 à #452 : 3/28/8 sur les 39 PR de référence (20,5 % de niveau haut pour une cible de 20 % ; écart justifié, les huit touchent la licence, les bases de profils et les migrations, ou `release.yml`, qui détient la clé de signature), contre 3/19/17 avec les motifs d'essai du document de départ, et 6/33/12 sur les 51 de la plage. Les « 39 dernières PR » de ce document venaient d'un tri par date de mise à jour (`limit=40&sort=recentupdate`) : liste figée dans la PR. **Décisions de Max** : une montée de version d'un paquet déjà présent dans un lock reste de niveau normal, correctifs d'advisory compris (2026-10-17) ; `db.ts` et `dataExportService.ts` restent normaux même quand le diff touche le verrou ou la restauration (2026-10-18). Preuves : script relancé à neuf contre l'API à chaque changement de règle, niveaux identiques PR par PR ; `--check` rouge sur une copie du tableau privée d'un motif ; CI `check-frontend` 562 verte (134 fichiers, 2248 tests). **Acquis** : l'API Forgejo marque un fichier modifié `changed`, pas `modified` (un test sur `modified` mettait toutes les PR de lock en haut) ; un contrôle de contenu de workflow doit ignorer les commentaires (#375 sortait haute sur le mot « toolchain ») ; les pages de l'API plafonnent à 50 éléments. Suivis : #461 (CSV de `src/__fixtures__/samples/` compilés par `include_str!` hors des `paths` de `check-rust`, `cargo-xwin` et toolchain non épinglés dans `release.yml`, commentaire périmé de `release.yml:104`), #462 (suggestions de la seconde revue, entrées du pilote #458), commentaire sur #457 (lecture sur `main`). Aucune migration (v1→v17). (ref #456, PR #460, #461, #462, #457, #458)
|
||||
- 2026-10-17 : **#369 mergée — `/fix-issue` (plan check refusé une fois) → `/pr-review` APPROVE → deux commits de suivi en tête → merge ff par l'API (PR #452, `main` `a8c3fd6`) : la Répartition réservée à l'édition Base.** L'écran de déverrouillage la rangeait déjà en Base (`upsell.features.adjustments`) alors que l'app la laissait ouverte en Gratuite ; désormais, en Gratuite, le bouton garde sa place, porte un cadenas et ouvre l'écran verrouillé (`UpsellDialog`) pour toute ligne, jamais la fenêtre de répartition ; bouton inactif tant que la licence n'est pas `ready` ; répartitions existantes comptées, icône orange gardée. `splitAccessFor` est plus strict que `profileGate` (`allowed` ne court-circuite pas `ready`) ; `TransactionTable` ne rend pas la fenêtre de répartition en `locked` mais la garde pendant un rafraîchissement de licence, et ne rend pas l'incitation en `open`. Refus du plan check (MAJOR) : le câblage de `TransactionTable` n'était pas testé, d'où `TransactionTable.wiring.test.ts` sur le harnais `hookHarness` de #409 ; second passage : pass. Preuves : 21 mutations de l'auteur, toutes rouges ; revue (commentaire 24129) avec 14 des 15 mutations du relecteur rouges, la survivante R14 (un clic verrouillé qui ouvrirait aussi la fenêtre de répartition) épinglée par `d5d0e89` ; `a8c3fd6` réunit la clé de la Répartition dans `SPLIT_FEATURE` ; ces deux commits en tête, non relus, sont nommés dans le commentaire 24148 ; tip en worktree `npm ci` propre (sonde rc=1, build, 2248 vitest sur 134 fichiers, contre 2222) ; CI 557 et 559 ; #369 fermée par le merge. Site : le tableau comparatif de la-compagnie-maximus#180 (PR #298 de ce dépôt-là), en production depuis le 2026-10-16, range déjà la Répartition en Base, en avance sur l'app, décalage accepté par Max ; commentaire 24164 posé le 2026-10-17 (mergé, publié avec la prochaine release, pas encore disponible). Suivis : #451 (incitation de `ProfileSwitcher` sur `UpsellDialog`, Échap, focus) et #454 (`type:bug`, compensation d'une transaction répartie laissée dans l'ancienne catégorie au reclassement, car `updateTransactionCategory` ne touche que le parent ; rare, mais irréparable en Gratuite depuis #369). Aucune migration (v1→v17). (ref #369, PR #452, #451, #454, la-compagnie-maximus#180)
|
||||
- 2026-10-16 : **#447 mergée — `/fix-issue` (plan check refusé une fois) → `/pr-review` APPROVE → merge ff par l'API (PR #448, `main` `1763a9f`) : `rustls` 0.23.45 → 0.23.46, corrigeant RUSTSEC-2026-0346 à -0349.** Publiés le 2026-10-14, les quatre avis rougissaient `audit.yml` (runs 553 et 555) sans que le lock ait bougé depuis la v0.17.1 ; repérés par la Phase 5.6 du `/warmup` le 2026-10-15, pas par le Défenseur, dont le check `deps` ne lance jamais `cargo audit` (**décision de Max** le 2026-10-15 : sous-check `cargo-audit`, agent-defenseurs#186). Aucun n'est atteignable (`rustls` atteint par `tauri-plugin-updater` → `reqwest` 0.13 seulement ; fournisseur `ring` sans groupe post-quantique, `brotli` non compilée, ni ECH ni `ClientSessionStore` maison), mais la règle 1 de `.cargo/audit.toml` dit corriger, pas supprimer. Diff : deux lignes de `Cargo.lock`, aucun bump compagnon, aucune arête `windows-sys` déplacée, une puce Sécurité par CHANGELOG. Refus du plan check (MAJOR) : « No behaviour change » avait déjà été recadré par `9abf474` au bump précédent, et la 0.23.46 apporte une dizaine d'autres corrections de la couche TLS (21 fichiers de `src/` en amont), donc l'entrée restreint « aucun changement » au code de l'application ; second passage : pass. Preuves : `cargo-audit` 0.22.2 (binaire de la CI) rouge sur l'arbre intact (4 IDs) puis vert ; `check-rust` run 554 lu ligne à ligne (gardes 2/2 et 9/9, 267 tests, 0 vulnérabilité ; à froid, rustc 1.99.1 du 2026-10-13 ayant changé la clé du cache) ; revue (commentaire 23853) avec mutation inverse rouge sur les quatre IDs ; tip en worktree propre (`npm ci`, sonde rc=1, build, 2222 vitest, `cargo test --locked` à 267, gardes, audit exit 0) ; après le merge, `audit.yml` en `workflow_dispatch` sur `main` : run 556 vert (666 crates, 12 warnings autorisés, 0 vulnérabilité). PR en `Refs #447` (critère post-merge), d'où une fermeture à la main (commentaire 23894). Pas de release corrective (décision de Max) : le bump part avec le prochain tag, cycle réel de mise à jour compris. **Acquis** : `cargo-audit` écrit « 1 vulnerability found! » mais « N vulnerabilities found! », chercher `vulnerabilit(y|ies) found` ou `error: [0-9]+ vulnerabilit` ; le journal de `check-rust` porte des codes ANSI (`CARGO_TERM_COLOR=always`) à retirer avant tout `grep`. Suivi #449 (`--locked` absent de `check-rust.yml` et de `release.yml`). Aucune migration (v1→v17). (ref #447, PR #448, #449, agent-defenseurs#186)
|
||||
- 2026-10-15 : **Milestone `planned-2026-10-12-retours-onboarding-v0171` mergée — `/autopilot` (8 workers en série, 1 h 14, worker N+1 lancé pendant la CI du maillon N, CI lue à chaque maillon, runs 543-551) → huit `/pr-review` APPROVE en parallèle (aucun bloquant) → merge ff par l'API, chaque enfant reciblé sur `main` (`main` `934bcbf`, 8 PRs `merged`).** Tip éprouvé en worktree `npm ci` propre : sonde rc=1, build, 2222 vitest (2065 avant), 267 Rust. 31 décisions HIGH, 25 MEDIUM, 0 LOW ; mutations des workers et des relecteurs presque toutes rouges, les survivantes sans effet de comportement ou épinglées. **Acquis** : deux workers sur huit ont perdu leur implémentation non commitée au `git checkout --` de la passe de mutations → consigne « commit avant la première mutation » ajoutée en cours de run, zéro incident ensuite ; les corps de PR des workers ne portent pas `Resolves #N` en clair (seulement les commits), sans effet sur un merge ff. Trou global relevé par deux revues : aucun test ne détecte une clé i18n présente dans une seule langue (#446). Aucune migration (v1→v17). (ref #424, #422, #427, #426, #430, #431, #425, #432, PRs #438-#445, #446)
|
||||
- 2026-10-13 : **Milestone `planned-2026-10-10-fiabilite-tests` mergée — `/autopilot` (4 workers en série, 1 h 25, CI lue à chaque maillon) → quatre `/pr-review` APPROVE en parallèle → suivis en tête de pile → merge ff par l'API, chaque enfant reciblé sur `main` (`main` `c17bf5d`, 4 PRs `merged`).** Mesure du rattrapage : 306 ms en release pour 100 001 transactions (T490, NVMe), sous la règle des 3 s, donc `STARTUP_TIMEOUT_MS` reste à 10 000 (ADR 0021). Inventaire de #389 : 27 fichiers et non 24, trois mocks bruts venus de #423. Suivis appliqués en tête (`74fae14`, `a1c31a1`, `ac3a981`, `86eff9b`, `c17bf5d`), non relus : `close()` d'un ancien proxy rejeté, échec de fermeture consigné sans bloquer le changement de profil, `withoutFolders` qui ne laisse plus passer un dossier contenant une espace, garde i18n plus stricte, badge traduit. Tip éprouvé en worktree `npm ci` propre : sonde rc=1, tsc, build, 2065 vitest, 262 Rust. ADR 0024 (serveur MCP, Proposed) rebasé sur le merge puis poussé (`977ef3a`). **Acquis** : l'orchestrateur a d'abord contredit à tort un constat juste du worker (badge `best-effort` codé en dur), sur un `grep` tronqué par `head` ; ne jamais conclure à l'absence depuis une sortie tronquée. Aucune migration (v1→v17). (ref #408, #409, #389, #395, PRs #433-#436, #437)
|
||||
- 2026-10-12 : **#423 mergée (PR #428, `main` `8898213`) — menus déroulants de catégorie avec saisie dans le formulaire de catégorie et le dialogue mot-clé.** Issue créée et analysée le jour même (`/analyze` : types `Category` vs `CategoryTreeNode` incompatibles → type structurel `CategoryLike` sur le combobox ; le formulaire ne connaissait pas l'id édité → prop `editingId` ; deux listes différentes → **D2** de Max : liste des Transactions ; **D1** : #423 porte l'exclusion UI, #393 garde la validation service). Plan check refusé une fois (la liste `absolute` était rognée par le scrollport du dialogue, réglé en sortant les champs du bloc défilant). Première revue **REQUEST_CHANGES** : la règle ne regardait que les enfants directs, une racine avec petits-enfants pouvait recevoir un parent (4ᵉ niveau) ; corrigé par la hauteur du sous-arbre, test rouge d'abord sur la sonde du relecteur. Seconde revue **APPROVE**, quatre suivis appliqués en tête (`8898213`, non relu : commentaires #393 corrigés, garde de cycle épinglée, espions sur `buildParentOptions`, garde redondante retirée). Preuves : 1963 vitest, 9 mutations rouges de l'auteur (une survivante remplacée : la règle « descendance » était impliquée par la règle de profondeur), CI 526-528, worktree `npm ci` propre. **Acquis** : `/commits/<sha>/statuses` renvoie l'historique (`pending` puis `success`), lire `/commits/<sha>/status` agrégé ; `head_commit_id` du merge exige le SHA complet (409 « head out of date » sur un SHA court). Aucune migration (v1→v17). (ref #423, PR #428, #429, #393)
|
||||
|
||||
## Blockers actifs
|
||||
|
||||
- Aucun blocker externe dur. `spec-monetisation` **fermée 12/12** (2026-07-08) — #50/#52/#53/#135/#136 livrées, ne sont plus des blockers.
|
||||
- Backlog ouvert (`status:ready`, non bloqué) : `spec-paiements` (#270 activation /v1 + product explicite + URL achat localisée — c'est elle qui activera le CTA « Obtenir » des UpsellGate, aujourd'hui désactivé « bientôt disponible » ; #271 absorbé par #301, livré) ; `spec-ci-build-optimization` **3/4** (#232 livrée le 2026-07-27 ; reste **#234** connectivité du serveur de cache runner, qui débloquerait `Swatinem/rust-cache` — accès hôte VPS requis). Le suivi « glob `src-tauri/**` non prouvé » ouvert avec #232 est **levé** : la PR #344 est le cas test isolé attendu (`Cargo.lock` + 2 `.md` seulement) — `check-rust` a bien tourné, `check-frontend` a bien été sauté par sa denylist.
|
||||
- Suivis des advisories : **#312, #313 et #315 fermées le 2026-08-15** (PRs #321/#322 mergées). **#314 fermée le 2026-08-15, prémisse infirmée** — elle affirmait « zéro run `schedule` », or `audit.yml` tire tous les jours à 06:00 UTC (**19 runs `schedule` verts** du 07-28 au 08-15, sans trou) ; elle a été écrite le 07-27 vers 23:56Z, ~6 h avant le premier tir programmé possible, et décrivait donc une fenêtre trop courte, pas un scheduler en panne. Ces 19 runs confirment au passage que `.cargo/audit.toml` est bien lu en conteneur **sur `main`** — l'ADR 0018 ne l'avait vérifié que par un `workflow_dispatch` sur une branche. **#343 fermée le 2026-09-04** (PR #344) — RUSTSEC-2026-0258 sur `h2`, la seule vulnérabilité qui rougissait l'audit depuis le 08-19 ; l'audit est de nouveau vert sur `main`, prouvé par `workflow_dispatch` (run 393) et non déduit du run de PR. Restent **#317** (re-évaluer `react-router` à une migration v8), **#319** (déclencheur de retrait pour `rsa`, désormais **seule** entrée de `.cargo/audit.toml` — le garde-fou est passé à 2 vérifications) et **#345** (domicilier la contrainte `windows-sys 0.61.2` — analysée le 09-04 : le corps d'origine, écrit de mémoire en fin de revue, se trompait sur trois points. Le déclencheur n'est pas « n'importe quel `cargo update` » mais tout bump atteignant le sous-arbre réseau/TLS — mesuré : `-p anyhow` ne bouge rien, `-p hyper` fait descendre les 7 — soit précisément là où tombent les advisories. #310 avait **bien** vu la dérive et l'avait jugée inoffensive *sur Linux*, où elle l'est ; son coût tombe sur le build Windows que personne ne mesurait : **3 versions de `windows-sys` compilées contre 5**. Aucun risque de correction — les 7 crates déclarent des bornes larges couvrant les deux états. Et cargo annonce « Locking 1 package » en écrivant 8 changements, donc le silence est le sien. Décision : garde-fou CI sur la version + ADR portant la condition de sortie. Le check croisé Windows depuis Linux a été **testé et écarté** — `cc-rs` s'arrête sur `lib.exe`, il faudrait `cargo-xwin`).
|
||||
- Nouveau bug ouvert : **#320** — les installations `.rpm` ne reçoivent aucune mise à jour (`latest.json` ne porte qu'une entrée `linux-x86_64` construite depuis le `.deb`, `release.yml:104-122`), alors que les `.rpm` continuent d'être publiés en assets. Documenté en §3 de `docs/qa-update-cycle.md` (« ne pas tester : cassé, pas seulement non vérifié »).
|
||||
- **Vérifications dues sur la v0.17.2 publiée** (étape 9 de `/release` faite le 2026-10-18 : `verify-release.sh` OK, Node `v24.22.0`, trois clés ; étape 10 faite sous Linux le 2026-10-18, trace dans le corps de la release ; restent les points ci-dessous, à Max) :
|
||||
- Retours d'onboarding v0.17.1 (#438-#445), dans l'app : cas de la section « Retours de la v0.17.1 » de `docs/qa-onboarding.html` et parcours guidé à huit étapes, en particulier `v0171-import-source-reopen`, `v0171-budget-card-leaves`, l'encadré « Structure attendue » dans les deux thèmes et la première ouverture d'un profil qui avait fini le parcours (#446).
|
||||
- Répartition réservée à Base (#369), dans l'app : en Gratuite, cadenas sur le bouton Répartition de la page Transactions et écran de déverrouillage au clic, jamais la fenêtre de répartition, lignes déjà réparties toujours marquées de l'icône orange ; en Base, fenêtre de répartition comme avant. Commentaire posé sur la-compagnie-maximus#180 à la publication (25050).
|
||||
- `release.yml` sous Node 24 (#376) : **fait** (`v24.22.0`, run 570). Le diff touchant `release.yml`, l'étape 10 de `/release` (cycle réel de mise à jour, `docs/qa-update-cycle.md`) s'applique aussi, selon la règle du skill : **faite sous Linux** (`.deb`, v0.17.1 → v0.17.2, Max, 2026-10-18 ; invite de mot de passe vue, type non consigné), **reste Windows**. Le bump de `rustls` de #447 (dépendance de `tauri-plugin-updater`) la rend obligatoire lui aussi. L'updater compilé sans `zip` (#455) est aussi dans cette release, mais ce cycle part de la v0.17.1, dont l'updater a encore `zip` : il ne prouve rien de l'updater sans `zip` (#463).
|
||||
- `latest.json` à trois clés (#320, clé générique `linux-x86_64` retirée le 2026-10-11) : **fait** (journal et `verify-release.sh`, run 570). Cycle réel depuis une installation `.rpm` (§3 de `docs/qa-update-cycle.md`) dès qu'une machine Fedora ou openSUSE est disponible, sinon « non déroulé » avec la raison dans la trace de la release.
|
||||
- Confirmations de #421 : **fait** (Max, sous Linux, 2026-10-18, concluant ; Windows non déroulé) ; #421 fermée à la main le même jour (commentaire 25074).
|
||||
- **Vérifications dues à la release suivante** (celle d'après la v0.17.2) : cycle réel de mise à jour Windows depuis la première version qui embarque #455 (et #447), première exécution de l'installation par l'updater sans `zip` et du téléchargement par `rustls` 0.23.46 ; dû même si cette release ne touche pas l'updater (#463).
|
||||
- **Le paiement est live : « aucun tag avant le go-live » n'a plus de raison d'être, et la release desktop qu'attendait le go-live est publiée (v0.16.0, 2026-10-03).** Bascule live le 2026-09-29 (Product et Price live à 19 $ CAD, webhook, variables Stripe de production, redeploy `3673d09` de maximus-api, URL `cs_live_` vérifiée), puis achat réel de contrôle le 2026-09-30 : clé sur `/merci` et par courriel, activation dans l'app, remboursement complet et licence révoquée par `charge.refunded`. **Volet serveur clos le 2026-10-03** (derniers commentaires relus le 2026-10-04) : maximus-api#62 fermée, tous critères cochés (maximus-api 0.4.2 en prod, secrets en runtime seul), avec la milestone `spec-paiements` de maximus-api (12/12) ; la-compagnie-maximus#150 (Link dans les textes légaux) livrée et fermée le 2026-10-01. **Go-live clos : la-compagnie-maximus#141** fermée le 2026-10-10 sur décision de Max, l'updater vérifié (Free refuse et invite à acheter, Base accepte ; confirmé le même jour). Restent pour mémoire, sans issue : le Dashboard Stripe (logo, paramètres Checkout, reçus automatiques, alertes d'échec de webhook) et les relevés du commentaire de fermeture. la-compagnie-maximus#184 (politique de confidentialité : données d'activation, contrôle au démarrage) est en `status:review` ; sa PR reste en brouillon jusqu'au déploiement de maximus-api#85 (commentaire du 2026-10-03). Les deux textes des suites de licence (contrôle à la saisie, retrait de la clé) y sont postés depuis le 2026-10-05 (commentaire 19724). **Vérifications manuelles sur la release publiée, toutes faites** : #365 le 2026-10-03, #346 et #350 le 2026-10-04 (voir Décisions). L'étape 10 sous Windows est faite (Michel, confirmée le 2026-10-10).
|
||||
- Backlog ouvert : **vague `planned-2026-09-24-paiements` close 3/3** (milestone fermée le 2026-09-30) : #270 (PR #359) et #346 (PR #362) mergées le 2026-09-27, #347 (PR #364, ferme aussi #360) mergée le 2026-09-30 ; volet site **clos** (maximus/la-compagnie-maximus#153, redirection de `/simpl-resultat` en prod). **#365 fermée le 2026-10-03** après sa vérification manuelle. **Milestone `planned-2026-10-03-suites-licence` fermée 3/3 le 2026-10-05** (#373, #370, #367 mergées, voir Décisions ; suivi #389 en `status:triage`). Hors milestone : **#369** (réserver le fractionnement de transactions à l'édition Base) **fermée le 2026-10-17** (PR #452, voir Décisions) ; **#372** (la liste complète des versions repousse le Journal en bas de Paramètres → Systèmes) reste en `status:ready`. **#350 fermée le 2026-10-04** (étape 3 vérifiée en production, voir Décisions) ; #363 fermée le 2026-10-10 sur décision de Max (ses tests, absorbés par #347, sont livrés dans la v0.16.0) ; réglés côté serveur et site : maximus-api#63 (clés de 100 ans) et maximus-api#70 (réactivation d'une machine désactivée), fermées et contenues dans `3673d09`, le commit déployé à la bascule du 2026-09-29, et la-compagnie-maximus#157, fermée, dont les CGV sont publiées en tronc commun plus annexe Simpl-Résultat (annexe relue en production le 2026-09-30, plus aucune durée de 10 ans) ; côté serveur, `/v1/prices` ne vérifie pas qu'une machine est encore active (une machine désactivée garde les cours jusqu'à l'expiration de son jeton), issue maximus-api à ouvrir ; suites de #350 ouvertes le 2026-10-01 : maximus-api#82 (`status:triage` : identifiants machine bruts déjà stockés, lignes désactivées comprises ; à rehacher avant l'expiration des premiers jetons bruts, sinon une réactivation crée une seconde ligne) et la-compagnie-maximus#184 (`status:review`, voir ci-dessus) ; go-live clos (la-compagnie-maximus#141 fermée le 2026-10-10, maximus-api#62 le 2026-10-03) ; `spec-paiements` fermée ici 5/5 ; onboarding phase 2 (parcours guidé) **mergée** : milestone `planned-2026-10-06-onboarding-parcours-guide` fermée 6/6 le 2026-10-08 (#396 à #401, `ee17f31`), suivis **#408** et **#409** (`status:triage`) ; onboarding phase 1 **livrée** : milestone `planned-2026-10-03-onboarding-phase1` fermée 7/7 le 2026-10-04 (#351 à #357, mergées en avance rapide, `667e058`) ; suivis ouverts : **#384** (`status:ready`, suivis non bloquants des revues) et **#371** (`status:blocked`, hors `/autopilot` : en-têtes CSV BMO à fournir par Max depuis un compte réel, un par type de compte, chèques/épargne et Mastercard, avec des valeurs fictives) ; analyse d'origine dans `docs/analyse-onboarding-2026-09.md`, plan détaillé dans les `spec-*-onboarding-phase1.md` locaux, ignorés par git ; #271 absorbé par #301, livré ; `spec-ci-build-optimization` **fermée 4/4** le 2026-10-05 (#232 livrée le 2026-07-27 ; #234 close le 2026-10-05 avec ses quatre preuves : dispatch run 464, PR #386 run 480, run planifié 488, `actcache` à 999 Mo) ; **#376** fermée le 2026-10-12 (Node 24, PR #414). Le suivi « glob `src-tauri/**` non prouvé » ouvert avec #232 est **levé** : la PR #344 est le cas test isolé attendu (`Cargo.lock` + 2 `.md` seulement) — `check-rust` a bien tourné, `check-frontend` a bien été sauté par sa denylist.
|
||||
- Suivis des advisories : **#312, #313 et #315 fermées le 2026-08-15** (PRs #321/#322 mergées). **#314 fermée le 2026-08-15, prémisse infirmée** — elle affirmait « zéro run `schedule` », or `audit.yml` tire tous les jours à 06:00 UTC (**19 runs `schedule` verts** du 07-28 au 08-15, sans trou) ; elle a été écrite le 07-27 vers 23:56Z, ~6 h avant le premier tir programmé possible, et décrivait donc une fenêtre trop courte, pas un scheduler en panne. Ces 19 runs confirment au passage que `.cargo/audit.toml` est bien lu en conteneur **sur `main`** — l'ADR 0018 ne l'avait vérifié que par un `workflow_dispatch` sur une branche. **#343 fermée le 2026-09-04** (PR #344) — RUSTSEC-2026-0258 sur `h2`, la seule vulnérabilité qui rougissait l'audit depuis le 08-19 ; l'audit est de nouveau vert sur `main`, prouvé par `workflow_dispatch` (run 393) et non déduit du run de PR. **#348 fermée le 2026-09-21** (PR #349) — RUSTSEC-2026-0285 sur `rustls`, seule vulnérabilité rougissant l'audit depuis le 09-15 (7 runs, 405→411) ; vert reprouvé par `workflow_dispatch` sur `main` (run 414 : 666 paquets, zéro vulnérabilité, 12 warnings autorisés). Deux advisories en cinq semaines sur le même chemin, celui de la mise à jour automatique. **#447 fermée le 2026-10-16** (PR #448, run 556 vert), quatre avis `rustls` sur ce même chemin ; le Défenseur n'audite pas le Rust (agent-defenseurs#186), `audit.yml` reste le seul canal pour les avis RustSec. **#455 fermée le 2026-10-18** (PR #459, run 564 vert), trois avis `zip` sur ce même chemin, retirés avec la feature `zip` de l'updater plutôt que supprimés. **#317** fermée le 2026-10-12 par la PR #417 (#358). Reste **#319** (déclencheur de retrait pour `rsa`, désormais **seule** entrée de `.cargo/audit.toml` — le garde-fou est passé à 2 vérifications). **#345** fermée le 2026-10-12 (PR #415) : garde `Verify windows-sys pinning` dans `check-rust.yml` et ADR 0023, qui portent la mesure, le coût (3 contre 5 versions compilées pour Windows) et la condition de sortie. Côté npm, **#358** fermée le 2026-10-12 (PR #417) : lock régénéré par `npm@11.21.0` dans les bornes de `package.json`, `npm audit` 8 → 0 au 2026-10-10.
|
||||
- **#320** fermée le 2026-10-12 (PR #416, clé générique retirée par la PR #418) : une clé `latest.json` par paquet Linux, vérifiée sur la v0.17.2 publiée (run 570, `verify-release.sh`) ; cycle `.rpm` encore dû (puce « Vérifications dues sur la v0.17.2 publiée ») ; le §3 de `docs/qa-update-cycle.md` est devenu la procédure de test rpm.
|
||||
- Suivis ouverts du chantier import (milestone fermée) : le refus du format « montant absolu + indicateur D/C » ne scanne que `amountCol ± 1`, donc une colonne D/C placée ailleurs passe encore ; dérives de comptes préexistantes dans `CLAUDE.md` et `docs/architecture.md` (composants, pages, services, `Version actuelle : 0.6.3` vs tag v0.14.0) mesurées et documentées en revue, non corrigées.
|
||||
- Release à considérer : le `[Unreleased]` porte le feature-gating, les deux lots d'advisories **et** le chantier import (migration v17) → candidate `v0.15.0` via `/release`. **L'étape 10 s'appliquera** : ces lots touchent `tauri-plugin-updater` et ses dépendances (`rustls-webpki`, `tar`, `plist`/`quick-xml`, `deep-link`), donc `docs/qa-update-cycle.md` est à dérouler pour de vrai — deux machines de bureau, une clé Base+ sur chacune, partir de la version précédente. Et #320 reste ouverte : les installations `.rpm` ne se mettront pas à jour, quelle que soit la release.
|
||||
- **Étape 10 de la release v0.15.0 consignée le 2026-09-13** — plus un blocker : cycle réel v0.14.0 → v0.15.0 validé par les deux testeurs ; trace rédigée pour le corps de la release (`PATCH` à appliquer par Max) ; invite d'authentification Linux non consignée ; #320 reste ouverte (`.rpm`). Deux restes : (1) le texte de la carte Compte Maximus (« Le compte est requis uniquement pour les fonctionnalités Premium », hérité de v0.7) contredit v0.15.0 où Premium vient de la clé, et a vraisemblablement conduit un testeur à se connecter pour rien (code de vérification Logto reçu par courriel) — reformulation livrée par #346 (PR #362), avec une réserve notée dans l'ADR 0017 (exacte tant que le chemin abonnement → Premium reste dormant) ; (2) le flux Logto exact (connexion par code, mot de passe oublié ou inscription) reste à lire dans le journal d'audit. Position produit : v0.17.2 (2026-10-18) ; `[Unreleased]` vide.
|
||||
|
|
|
|||
|
|
@ -40,3 +40,7 @@ Les 6 migrations actuelles :
|
|||
- **Immutabilité** : une migration appliquée ne peut jamais être modifiée (erreur de checksum), ce qui force la création de nouvelles migrations correctives
|
||||
- **Synchronisation manuelle** : le fichier `consolidated_schema.sql` doit être mis à jour manuellement après chaque nouvelle migration
|
||||
- **Pas de rollback** : les migrations sont unidirectionnelles (pas de `down`)
|
||||
|
||||
## Amendement #390 (2026-10-06)
|
||||
|
||||
Le schéma consolidé **crée** un profil secondaire, il ne le tient pas à jour. tauri-plugin-sql n'applique les migrations qu'à la base enregistrée par `add_migrations`, celle du profil par défaut (`simpl_resultat.db`). Jusqu'à #390, un profil secondaire gardait le schéma de la version qui l'avait créé. Depuis #390, un nouveau profil reçoit à sa création l'historique complet de `_sqlx_migrations`, et la commande `prepare_profile_db` applique à chaque ouverture les versions qui manquent à un profil secondaire. Voir l'[ADR 0021](0021-migrations-profils-secondaires.md). Les six migrations listées plus haut sont celles de 2025 ; il y en a dix-sept à la v0.17.0.
|
||||
|
|
|
|||
|
|
@ -38,3 +38,12 @@ Architecture :
|
|||
- **Pas de vue agrégée** : impossible de voir les données de tous les profils en même temps
|
||||
- **Synchronisation du schéma** : chaque base doit être au même niveau de migration (géré par le plugin sql au démarrage)
|
||||
- **Fichier `profiles.json` externe** : les métadonnées des profils ne sont pas dans SQLite, ce qui crée une dépendance à un fichier JSON séparé
|
||||
|
||||
## Amendement #390 (2026-10-06)
|
||||
|
||||
Deux affirmations des conséquences étaient fausses jusqu'à #390 :
|
||||
|
||||
- « Les migrations s'appliquent indépendamment à chaque base » et « géré par le plugin sql au démarrage » : le plugin ne migre que la base du profil par défaut. Un profil secondaire gardait le schéma de la version qui l'avait créé. Il rattrape désormais ses migrations à chaque ouverture ([ADR 0021](0021-migrations-profils-secondaires.md)).
|
||||
- « Supprimer un profil = supprimer un fichier `.db` » : sous Linux, la commande cherchait la base sous `app_data_dir()` alors que le plugin la range sous `app_config_dir()`, et le fichier restait sur le disque. La suppression vise désormais le dossier du plugin et emporte aussi les fichiers annexes de SQLite et les copies prises avant un rattrapage.
|
||||
|
||||
`profiles.json` reste dans le répertoire de données de l'application (`app_data_dir()`) ; les bases sont dans le répertoire de configuration (`app_config_dir()`), qui n'est le même dossier que sous Windows et macOS.
|
||||
|
|
|
|||
|
|
@ -4,22 +4,44 @@
|
|||
- Date: 2026-07-20
|
||||
- Issues: #297 (socle LicenseContext + matrice + `useEntitlement`), #298 (garde `RequireFeature` + `UpsellGate`), #299 (routes gatées + cadenas Sidebar/tuiles), #300 (multi-profils non destructif), #301 (Rust : `auto-update` Base+, `features[]`, dev-override), #302 (cette doc)
|
||||
- Spec: [`spec-decisions-feature-gating.md`](../../spec-decisions-feature-gating.md), [`spec-plan-feature-gating.md`](../../spec-plan-feature-gating.md)
|
||||
- S'appuie sur la licence JWT Ed25519 machine-bindée (Phase 3b monétisation) et sur [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) (récupération de cours via maximus-api, seul gate appliqué côté serveur)
|
||||
- S'appuie sur la licence JWT Ed25519 (Phase 3b monétisation ; liée à la machine côté serveur seulement depuis #347, voir l'amendement en tête) et sur [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) (récupération de cours via maximus-api, seul gate appliqué côté serveur)
|
||||
|
||||
> **Amendement du 2026-09-27 (#346)** — le CTA d'achat n'est plus inerte. « Obtenir Base » est actif sur tous les écrans verrouillés Base : il ouvre la page produit dans la langue de l'app (`purchaseUrl`, livré par #270, via le composant `PurchaseButton`). Premium reste désactivé, avec la note « Premium n'est pas encore offert en ligne. », tant qu'aucune offre Premium n'est vendable ; la règle vit dans une fonction pure testée, `upsellCtaFor` (`src/shared/upsellCta.ts`). En édition Gratuite, la carte Mises à jour et la page d'erreur proposent aussi « Obtenir Base » et « Télécharger la dernière version » (page produit), sans vérification de mise à jour ni appel réseau. `ProfileFormModal` réutilise désormais `UpsellGate` : le « mode upsell compact » a disparu, et « J'ai déjà une clé » ne s'affiche que sous un routeur (`HaveKeyButton`). Réserve sur la carte Compte Maximus : son texte dit que les éditions viennent de la clé de licence, pas du compte, ce qui n'est exact qu'en pratique. `current_entitlements` accorde encore Premium à un compte dont le cache signé porte un abonnement actif (`check_account_edition`, `license_commands.rs`), comme le rappelle le Contexte ci-dessous. Ce chemin reste dormant tant qu'aucun abonnement n'est vendu ; le jour où un abonnement existera, il faudra revoir ce texte ou retirer le chemin. Le reste de la décision est inchangé.
|
||||
|
||||
> **Amendement du 2026-09-29 (#347)** — l'édition locale ne dépend plus du jeton d'activation (décision D12, qui ferme #360). La contrainte 2 du Contexte affirmait qu'une `license.key` copiée sur une autre machine était déclassée en `free` : c'était faux pour une clé copiée **seule**, qui gardait son édition (état « pré-activation » toléré). Seule la copie clé + jeton retombait en Gratuite, c'est-à-dire le cas d'un utilisateur légitime qui restaure ses données après une réinstallation ou un changement de poste ; et une machine activée perdait son édition à l'expiration de son jeton, signé pour 2 ans alors que la clé en dure 100 depuis maximus-api#63. La règle ne protégeait donc rien et piégeait des acheteurs. Désormais `current_entitlements` résout l'édition et les `features[]` depuis la **seule clé** : une clé valide et non expirée donne son édition, quel que soit le jeton ; une clé invalide ou expirée donne `("free", [])`, et le court-circuit fail-closed en Free (CWE-863) ne vise plus que ce cas. La licence n'est liée à la machine que **côté serveur** : activation, limite de machines, et cours du Bilan (`/v1/prices` exige un jeton valide, [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md)). Le jeton garde une validation stricte là où il décide de quelque chose : l'état « Activée » de la carte Licence (`get_activation_status`) et l'écriture d'un jeton reçu du serveur. Enregistrer une autre clé supprime le jeton de la précédente : il désignerait l'ancienne licence auprès du serveur des cours. La clé reste validée strictement (`exp` et `iat` exigés, CWE-613). Les mentions de liaison à la machine plus bas (Contexte, §1, §5, Liens) se lisent à la lumière de cet amendement.
|
||||
|
||||
> **Amendement du 2026-10-02 (#365)** — une clé révoquée par le serveur de licences retombe en Gratuite, et l'app envoie pour cela sa première requête non sollicitée. Constat du go-live (2026-09-30) : une licence remboursée, révoquée côté serveur, restait pleinement fonctionnelle, mises à jour automatiques comprises. L'édition se résout hors ligne depuis la clé, dont l'échéance est à 100 ans depuis maximus-api#63, et le signalement de #347 (D13, « rien n'est persisté ») ne vivait que le temps de la session, sans toucher l'édition. Les conditions de vente disent pourtant que le remboursement entraîne la révocation de la licence.
|
||||
>
|
||||
> **Ce qui change.** D13 est remplacée pour le cas révoqué : l'état vit dans `license.check`, à côté de la clé, sous la forme `{key_sha256, attempted_at, revoked}`. La clé n'y figure que par son condensé SHA-256. `entitlements_from_dir` rend `("free", [])` quand cet état dit `revoked` pour la clé enregistrée : `get_edition`, `check_entitlement` (`auto-update`) et la matrice du front suivent sans autre changement. D12 est inchangée, le jeton d'activation ne pèse toujours pas sur l'édition. Un fichier absent, illisible, au JSON invalide, ou qui parle d'une autre clé se lit « pas d'état » : la clé n'est pas tenue pour révoquée et un contrôle est dû. Un fichier corrompu ne rétrograde donc jamais un client légitime, et une autre clé enregistrée reprend son édition.
|
||||
>
|
||||
> **La requête au démarrage et à la saisie d'une clé, et ses bornes.** Jusqu'ici aucune requête ne partait au lancement : les mises à jour, les cours et la rétroaction partent sur un clic, et le contrôle d'un compte Maximus connecté à l'ouverture de Paramètres → Utilisateurs ou du formulaire de rétroaction. `check_license_revocation` est la première exception, bornée ainsi : (1) rien ne part sans une clé enregistrée qui se valide localement ; (2) au plus une tentative par 24 h, quel qu'en soit le résultat, parce que `attempted_at` est écrit **avant** l'appel ; (3) la requête est `POST /v1/licenses/verify` avec le produit et la clé, sans identifiant machine, nom d'ordinateur ni donnée d'usage ; (4) chaque contrôle écrit son issue dans le Journal de l'app, y compris « rien envoyé » quand la dernière tentative a moins de 24 h ; (5) le contrôle part au démarrage et à la saisie d'une clé, sans minuteur en cours de session, toujours au plus une tentative par 24 h et par clé (`license.check` ne retient que la dernière clé contrôlée : revenir à une clé précédente en rend un nouveau dû). Le contrôle à la saisie a été ajouté par #373 (2026-10-04) : jusque-là, une clé saisie en cours de session n'était contrôlée qu'au démarrage suivant, et une clé révoquée jamais contrôlée sur ce poste gardait son édition d'ici là, puisqu'aucune activation n'est requise depuis #347. Il part en arrière-plan, après l'enregistrement de la clé : la saisie rend la main sans attendre le serveur, hors ligne comme en ligne, et une clé révoquée affiche son édition une à deux secondes avant de passer en Gratuite. Rust ne laisse passer qu'un contrôle à la fois, verrou tenu jusqu'au verdict (`check_revocation_in_turn`) : une clé saisie avant la fin du chargement initial, contrôlée par la saisie et par le démarrage, n'envoie qu'une requête, le second contrôle lisant la tentative du premier ; et le contrôle d'une clé saisie pendant celui d'une autre attend ce verdict au lieu de l'écraser. Écartés : un appel à chaque démarrage (le serveur verrait chaque ouverture de l'app), un contrôle à la seule ouverture de la carte Licence (un remboursé qui n'ouvre jamais cette page garderait son édition) et, pour #373, attendre la réponse dans la saisie (l'indicateur de validation tournerait jusqu'à 15 s hors ligne).
|
||||
>
|
||||
> **Fail-open.** Seule une réponse « révoquée » pose l'état, seule une réponse `valid:true` le lève. Erreur réseau, délai dépassé, 429, 5xx, réponse illisible, `not_found` et `expired` le laissent tel quel : un utilisateur hors ligne n'est jamais rétrogradé, et une clé connue comme révoquée le reste. Aucun chemin serveur ne réactive une licence aujourd'hui ; la levée ne sert qu'à une correction manuelle de la base. Une clé connue comme révoquée reste donc contrôlée une fois par 24 h, tant qu'elle est enregistrée (décision de Max du 2026-10-03, à la revue de la PR #368) : c'est ce qui reprend une telle correction sans rien demander à l'utilisateur. Le coût est assumé et déclaré dans le guide : le serveur reçoit chaque jour la clé d'un acheteur remboursé qui continue d'utiliser l'app, tant qu'elle reste enregistrée. Écarté : arrêter le contrôle à la révocation, ce qui aurait obligé à émettre une nouvelle clé pour réparer une révocation faite par erreur.
|
||||
>
|
||||
> **Le retrait de la clé (#370, 2026-10-04).** Jusque-là, aucune interface ne retirait une clé enregistrée, et seul l'enregistrement d'une autre clé arrêtait cet envoi. « Retirer la clé », dans la carte Licence, l'arrête aussi : `remove_license` supprime `license.key` d'abord, puis le jeton d'activation, et une suppression qui échoue rend `storage` sans jamais laisser la clé sans son jeton. `license.check` est gardé : indexé par le condensé de la clé, il ne pèse sur aucune autre, et le supprimer rendrait son édition à une clé révoquée retirée puis ressaisie jusqu'au contrôle suivant, un contournement mis à la portée de tous par un bouton. Une telle clé reste donc en Gratuite, par cet état si aucune autre clé n'a été contrôlée entre-temps, sinon par le contrôle à la saisie (#373) ; hors ligne après le contrôle d'une autre clé, elle garde son édition jusqu'au contrôle suivant (fail-open, ci-dessus). Sur une machine activée avec une clé valide, le retrait libère d'abord sa place sur le serveur de licences (décision de Max du 2026-10-03) ; si le serveur ne répond pas, rien n'est supprimé et l'app demande s'il faut retirer la clé quand même, la place restant occupée.
|
||||
>
|
||||
> **Les autres sources.** L'activation, la liste des machines et la désactivation enregistrent le même état quand le serveur leur répond `license_revoked`, sous la clé qu'elles viennent d'envoyer et jamais sous une clé relue ensuite. La route des cours, elle, **nomme le refus sans l'enregistrer**, ce qui s'écarte du corps de l'issue (décision de Max, 2026-10-02) : `/v1/prices` juge la licence du jeton d'activation, que le client ne sait pas relier à la clé enregistrée, et la v0.15.0 laissait le jeton d'une clé précédente à côté d'une nouvelle clé. Enregistrer ce refus aurait rétrogradé une clé valide dans ces dossiers. Le 403 `license_revoked` s'affiche donc comme une révocation de l'activation, plus comme « abonnement premium requis ». Depuis #367 (2026-10-04), ce refus supprime aussi le jeton refusé, toujours sans écrire l'état de la clé (décision de Max du 2026-10-03) : ce jeton ne sert plus à rien, quelle que soit la clé enregistrée, et la carte Licence repropose « Activer », qui répare une clé valide ; si la clé enregistrée est elle-même révoquée, « Activer » le dit et l'enregistre sous cette clé. La suppression n'a lieu que si le fichier vaut encore le jeton présenté : une réactivation arrivée pendant la requête refusée garde son jeton neuf. La liste des machines supprime de même un jeton qu'aucune de ses lignes ne représente (règle 4 de l'[ADR 0020](0020-identifiant-machine-derive.md)). Chacune de ces suppressions écrit une ligne au Journal, à code fixe.
|
||||
>
|
||||
> **Côté interface.** `LicenseContext` lit l'état depuis Rust (`info.revoked`) et ne décide plus rien ; la carte Licence, chaque écran verrouillé (`UpsellGate`) et la carte Mises à jour en Gratuite nomment la révocation, sans bandeau global ; la page d'erreur, page de repli après un plantage, reste sans cette ligne (revue de la PR #368). Une clé révoquée ressaisie reste en Gratuite.
|
||||
>
|
||||
> **Ce que cet amendement ne change pas.** Le §2 tient toujours : c'est un soft-paywall, pas un contrôle d'accès. Bloquer le serveur de licences, rester hors ligne ou supprimer `license.check` suffit à garder l'édition (dans ce dernier cas jusqu'au contrôle suivant, dû dès le prochain démarrage ou la prochaine saisie de la clé), et une app laissée ouverte n'est recontrôlée qu'à son redémarrage ou à la saisie d'une clé (#373). La révocation aligne le cas ordinaire, un acheteur remboursé qui ne fait rien de particulier, sur ce que disent les conditions de vente. La mention du contrôle dans la politique de confidentialité du site, y compris sa poursuite après une révocation, est suivie par maximus/la-compagnie-maximus#184.
|
||||
|
||||
> **Amendement du 2026-10-16 (#369)** — la répartition d'une transaction sur plusieurs catégories (bouton « Répartition » de la page Transactions) passe dans l'édition Base, avec les ajustements, sur décision de Max du 2026-10-03 (analyse de maximus/la-compagnie-maximus#180). L'écran de déverrouillage le promettait déjà (`upsell.features.adjustments`) alors que l'app ne la verrouillait pas : le code s'aligne sur l'offre, il ne la change pas. La page Transactions reste Free ; seul son bouton est gaté, par la clé `adjustments`. En Gratuite, le bouton garde sa place et son icône, porte un cadenas et ouvre l'écran verrouillé dans une fenêtre (`UpsellDialog`) au lieu de la fenêtre de répartition, pour toute ligne, répartie ou non. Tant que la licence n'est pas `ready`, il n'affiche aucun cadenas et ne fait rien (`splitAccessFor`, plus strict que `isProfileSwitchLocked` : `allowed` n'y court-circuite pas `ready`). Conformément au §5, les répartitions existantes (`transactions.parent_transaction_id`, `is_split`) restent en base, comptées par les rapports et le tableau de bord, et signalées par l'icône orange ; seules leur ouverture et leur modification sont verrouillées. Écartés : une fenêtre de répartition en lecture seule, et l'annulation d'une répartition permise en Gratuite.
|
||||
|
||||
## Contexte
|
||||
|
||||
Simpl'Résultat vend trois éditions — **Gratuite** (`free`), **Base** (`base`), **Premium** (`premium`) — résolues localement depuis une clé de licence JWT Ed25519 liée à la machine (ou depuis un abonnement Compte Maximus pour Premium). Jusqu'au chantier #297-#301, cette édition n'était presque pas consommée : seul le gate `auto-update` (Rust, `check_entitlement`) et le fetch de cours (serveur) en dépendaient. Tous les modules de l'app étaient accessibles quelle que soit l'édition.
|
||||
Simpl'Résultat vend trois éditions — **Gratuite** (`free`), **Base** (`base`), **Premium** (`premium`) — résolues localement depuis une clé de licence JWT Ed25519 (liée à la machine côté serveur seulement depuis #347, voir l'amendement en tête ; ou depuis un abonnement Compte Maximus pour Premium). Jusqu'au chantier #297-#301, cette édition n'était presque pas consommée : seul le gate `auto-update` (Rust, `check_entitlement`) et le fetch de cours (serveur) en dépendaient. Tous les modules de l'app étaient accessibles quelle que soit l'édition.
|
||||
|
||||
Le recadrage du périmètre des abonnements par Max fixe la matrice suivante :
|
||||
|
||||
| Module | Gratuite | Base | Premium |
|
||||
|---|---|---|---|
|
||||
| Tableau de bord, Import, Transactions, Catégories | ✓ | ✓ | ✓ |
|
||||
| Tableau de bord, Import, Transactions (sans la Répartition, #369), Catégories | ✓ | ✓ | ✓ |
|
||||
| Rapports : hub `/reports` + Tendances | ✓ | ✓ | ✓ |
|
||||
| Export / import chiffré, Changelog, Paramètres | ✓ | ✓ | ✓ |
|
||||
| Profil (un seul) | ✓ | ✓ | ✓ |
|
||||
| Budget | — | ✓ | ✓ |
|
||||
| Ajustements | — | ✓ | ✓ |
|
||||
| Ajustements, Répartition des transactions (bouton de la page Transactions, #369) | — | ✓ | ✓ |
|
||||
| Rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes) | — | ✓ | ✓ |
|
||||
| Profils multiples | — | ✓ | ✓ |
|
||||
| Mises à jour automatiques | — | ✓ | ✓ |
|
||||
|
|
@ -30,7 +52,7 @@ Pas de quatrième édition « admin » : Max s'auto-émet une licence Premium.
|
|||
Trois contraintes structurent la solution :
|
||||
|
||||
1. **L'app est GPL-3.0.** Tout enforcement embarqué dans le client est contournable par recompilation — un « durcissement » côté Rust n'apporterait aucune garantie réelle, seulement de la complexité (un aller-retour IPC par gate, gestion d'erreur, latence au boot d'une app offline-first).
|
||||
2. **La licence est machine-bindée.** Une clé `license.key` copiée sur une autre machine est déclassée en `free` par le chemin de validation (token d'activation ↔ machine id). Mais le JWT copié porte toujours son tableau signé `features[]` (overrides par-licence) : sans précaution, un check « matrice OU features » re-donnerait à une clé déclassée les fonctionnalités qu'elle liste — c'est une autorisation incorrecte (CWE-863).
|
||||
2. **La licence est machine-bindée.** Une clé `license.key` copiée sur une autre machine est déclassée en `free` par le chemin de validation (token d'activation ↔ machine id). Mais le JWT copié porte toujours son tableau signé `features[]` (overrides par-licence) : sans précaution, un check « matrice OU features » re-donnerait à une clé déclassée les fonctionnalités qu'elle liste — c'est une autorisation incorrecte (CWE-863). *Inexact avant même #347 : une clé copiée sans son jeton gardait son édition ; depuis #347 le jeton ne déclasse plus rien, seule une clé invalide ou expirée retombe en `free` (voir l'amendement en tête).*
|
||||
3. **Le contexte licence n'existait pas côté React.** `useLicense` invoquait `get_edition` à chaque appel (asynchrone, par composant) — inutilisable pour gater des routes et des items de navigation sans flash ni cascade d'IPC.
|
||||
|
||||
## Décision
|
||||
|
|
@ -63,11 +85,11 @@ Le gating est appliqué **uniquement dans l'interface** (routes, navigation, poi
|
|||
|
||||
### 4. Upsell verrouillé visible, jamais masqué
|
||||
|
||||
Les modules non inclus dans l'édition restent **visibles et cliquables** : cadenas dans la Sidebar (`NavLock`, sur `budget`/`adjustments`/`balance`) et sur les tuiles de rapports avancés du hub, affichés seulement si `ready && !allowed`. Les routes gatées sont enveloppées dans des layout-routes `RequireFeature` (loader neutre si `!ready`, `<Outlet/>` sinon) qui rendent `UpsellGate` en cas de refus : écran verrouillé avec le tier requis, un CTA « Obtenir \<tier\> » **désactivé** avec la mention « bientôt » (le flux d'achat en ligne sera câblé par #270/Stripe), et « J'ai déjà une clé » qui mène à la carte licence (`/settings/users`). Le hub `/reports` et `/reports/trends` restent Free et **hors de tout gate**.
|
||||
Les modules non inclus dans l'édition restent **visibles et cliquables** : cadenas dans la Sidebar (`NavLock`, sur `budget`/`adjustments`/`balance`) et sur les tuiles de rapports avancés du hub, affichés seulement si `ready && !allowed`. Les routes gatées sont enveloppées dans des layout-routes `RequireFeature` (loader neutre si `!ready`, `<Outlet/>` sinon) qui rendent `UpsellGate` en cas de refus : écran verrouillé avec le tier requis, un CTA « Obtenir \<tier\> » **désactivé** avec la mention « bientôt » (le flux d'achat en ligne sera câblé par #270/Stripe ; voir l'amendement en tête), et « J'ai déjà une clé » qui mène à la carte licence (`/settings/users`). Le hub `/reports` et `/reports/trends` restent Free et **hors de tout gate**. Depuis #369, une action gatée peut aussi vivre dans une page Free : le bouton Répartition de la page Transactions porte le cadenas (`SplitButton`, si `ready && !allowed` pour `adjustments`) et ouvre `UpsellGate` dans une fenêtre (`UpsellDialog`).
|
||||
|
||||
### 5. Durcissement sec, non destructif
|
||||
|
||||
Le gating **bloque l'accès, jamais les données**. Rien n'est supprimé ni migré quand l'édition baisse (expiration, clé retirée, machine changée) : les budgets, ajustements, snapshots de bilan et profils restent intacts dans leurs bases SQLite, et tout réapparaît dès qu'une clé valide est saisie. Cas particulier multi-profils (`src/shared/profileGate.ts`, prédicats purs) : un utilisateur Free garde **toujours** l'accès à son profil actif ; seuls le passage à un autre profil et la création d'un profil supplémentaire sont verrouillés, la création étant gatée au point unique `ProfileFormModal` (mode upsell compact). Si aucun profil actif ne se résout (état dégénéré), rien n'est verrouillé — on n'enferme jamais l'utilisateur hors de tous ses profils.
|
||||
Le gating **bloque l'accès, jamais les données**. Rien n'est supprimé ni migré quand l'édition baisse (expiration, clé retirée, clé révoquée depuis #365 ; un changement de machine ne la fait plus baisser depuis #347, voir les amendements en tête) : les budgets, ajustements, snapshots de bilan et profils restent intacts dans leurs bases SQLite, et tout réapparaît dès qu'une clé valide est saisie. Cas particulier multi-profils (`src/shared/profileGate.ts`, prédicats purs) : un utilisateur Free garde **toujours** l'accès à son profil actif ; seuls le passage à un autre profil et la création d'un profil supplémentaire sont verrouillés, la création étant gatée au point unique `ProfileFormModal` (mode upsell compact ; voir l'amendement en tête). Si aucun profil actif ne se résout (état dégénéré), rien n'est verrouillé — on n'enferme jamais l'utilisateur hors de tous ses profils. Même règle pour les répartitions de transactions (#369) : en Gratuite, elles restent comptées par les rapports et signalées dans la liste ; seules leur ouverture et leur modification sont verrouillées.
|
||||
|
||||
### 6. Dev-override compilé hors des builds normaux
|
||||
|
||||
|
|
@ -108,7 +130,7 @@ Faire porter tout le gating par le tableau signé de chaque licence. Rejeté : c
|
|||
### Négatives / risques actés
|
||||
|
||||
- **Contournable par build local** : assumé (GPL, soft-paywall). Ne jamais présenter ce gating comme une protection — la seule barrière réelle est serveur (cours).
|
||||
- **CTA d'achat inerte** : « Obtenir \<tier\> » est affiché désactivé (« bientôt ») tant que #270 (activation en ligne + URL d'achat) n'est pas livré. Fenêtre où l'upsell promet sans vendre — la voie « J'ai déjà une clé » reste fonctionnelle.
|
||||
- **CTA d'achat inerte** : « Obtenir \<tier\> » est affiché désactivé (« bientôt ») tant que #270 (activation en ligne + URL d'achat) n'est pas livré. Fenêtre où l'upsell promet sans vendre — la voie « J'ai déjà une clé » reste fonctionnelle (voir l'amendement en tête).
|
||||
- **Deux tables à ne pas confondre** : un futur gate ajouté côté Rust dans `FEATURE_TIERS` ne gaterait rien dans l'UI, et réciproquement. La règle est documentaire (cet ADR + commentaires des deux modules).
|
||||
- **`dev-override` à surveiller en release** : la feature Cargo ne doit jamais apparaître dans un build publié ; le choix opt-in la rend improbable, pas impossible.
|
||||
|
||||
|
|
@ -124,6 +146,7 @@ Faire porter tout le gating par le tableau signé de chaque licence. Rejeté : c
|
|||
- `src/hooks/useEntitlement.ts` — `{ allowed, ready }` ; `src/hooks/useIsPremium.ts` — raccourci Premium
|
||||
- `src/components/shared/RequireFeature.tsx` / `UpsellGate.tsx` — garde de route + écran verrouillé
|
||||
- `src/shared/profileGate.ts` — prédicats multi-profils non destructifs ; `ProfileFormModal` (point unique de création)
|
||||
- `src-tauri/src/commands/entitlements.rs` — `FEATURE_TIERS`, `is_entitled` (CWE-863) ; `license_commands.rs` — `current_entitlements` (machine-binding), `dev_override_edition` (CWE-489)
|
||||
- `src/shared/splitGate.ts` — accès du bouton Répartition (`open`, `locked`, `pending` ; #369) ; `src/components/transactions/SplitButton.tsx` ; `src/components/shared/UpsellDialog.tsx` — écran verrouillé dans une fenêtre
|
||||
- `src-tauri/src/commands/entitlements.rs` — `FEATURE_TIERS`, `is_entitled` (CWE-863) ; `license_commands.rs` — `current_entitlements` (la clé seule depuis #347, et son état de révocation depuis #365), `check_revocation_in` (contrôle au démarrage et à la saisie d'une clé, ce dernier depuis #373), `check_revocation_in_turn` (un contrôle à la fois, #373), `remove_license_in` (retrait de la clé, `license.check` gardé, #370), `dev_override_edition` (CWE-489)
|
||||
- [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) / [ADR 0011](0011-providers-best-effort-yahoo.md) — le gate serveur des cours, seul enforcement dur
|
||||
- Issues #297 → #302 (milestone `planned-2026-07-19-feature-gating`) ; #271 (absorbée) ; #270 (câblage du CTA d'achat, à venir)
|
||||
- Issues #297 → #302 (milestone `planned-2026-07-19-feature-gating`) ; #271 (absorbée) ; #270 (URL d'achat localisée) ; #346 (CTA d'achat actif, voir l'amendement en tête) ; #347 (l'édition suit la seule clé) ; #365 (révocation vérifiée en ligne) ; #373 (contrôle à la saisie d'une clé, un contrôle à la fois) ; #370 (retrait de la clé enregistrée) ; #367 (jeton d'activation périmé laissé par la v0.15.0) ; #369 (Répartition réservée à l'édition Base, voir l'amendement en tête)
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@
|
|||
|
||||
- Status: **Accepted**
|
||||
- Date: 2026-07-27
|
||||
- Issues: #310 (les 9 advisories découvertes, cette décision), #312 (déclencheur de retrait des entrées `quick-xml`), #314 (le cron de `audit.yml` n'a jamais démarré)
|
||||
- Issues: #310 (les 9 advisories découvertes, cette décision), #312 (déclencheur de retrait des entrées `quick-xml`), #314 (le cron de `audit.yml` n'a jamais démarré), #455 (canari `tar` → `webkit2gtk`)
|
||||
- S'appuie sur #232 (split de `check.yml`, création de `audit.yml`, retrait du `|| true` qui masquait la sortie de `cargo audit`)
|
||||
|
||||
> **Amendement du 2026-07-27 (#312/#313)** — la décision et ses trois règles sont inchangées ; seules les entrées ont bougé. Les deux advisories `quick-xml` ont **quitté la liste le jour même** : `plist` 1.10.0 tire `quick-xml` 0.41.0, qui porte le correctif, dans la borne existante de `tauri`. Elles sont donc **résolues, pas acceptées**, et le mécanisme a fonctionné comme prévu — la vérification par cible a montré qu'un correctif était devenu atteignable, et l'entrée est partie. Il ne reste que `RUSTSEC-2023-0071` (`rsa`), et le garde-fou ne boucle plus que sur ce crate.
|
||||
>
|
||||
> Trois passages ci-dessous sont **datés du 2026-07-27 avant ce bump** et à lire comme historiques : `quick-xml` comme exemple de l'insuffisance de `--target all` (règle 1), l'entrée `quick-xml` du tableau de contexte, et l'alternative « override / `[patch.crates-io]` » — cette dernière **n'est plus vraie**, `plist` 1.10.0 déclarant désormais `quick-xml ^0.41.0`. L'argument de fond qu'elle illustre (`[patch.crates-io]` ne franchit pas une frontière semver-incompatible) reste correct, seul le cas d'espèce a disparu.
|
||||
|
||||
> **Amendement du 2026-10-17 (#455)** — le canari du garde-fou change ; la décision et ses trois règles restent. Pour retirer `zip` 4.6.1 (RUSTSEC-2026-0367 à -0369, sans version corrigée compatible avec l'updater), `tauri-plugin-updater` est compilé sans sa feature `zip`, qui tirait aussi `tar` : `tar` a quitté le graphe Linux, et le canari d'origine aurait rougi par construction. Il est remplacé par `webkit2gtk`, la webview Linux de Tauri (`wry`), qu'un build Linux de Tauri v2 ne peut pas perdre. Le choix garde la propriété qui faisait la valeur de `tar` : une dépendance présente sur `x86_64-unknown-linux-gnu` et absente de `x86_64-pc-windows-msvc`, dont la présence prouve aussi que `--target` résout les arêtes conditionnelles sur lesquelles repose la règle 1. `tauri`, envisagé à l'analyse de #455, est inconditionnel et n'aurait prouvé que l'affichage de `cargo tree`.
|
||||
|
||||
## Contexte
|
||||
|
||||
#232 a retiré le `|| true` qui avalait la sortie de `cargo audit` et créé `audit.yml`, un job quotidien (06:00 UTC) **volontairement bloquant** : il *est* le canal de notification pour les advisories publiées entre deux PR Rust, `check-rust.yml` ne tournant que sur ~1 PR sur 40.
|
||||
|
|
@ -52,7 +54,7 @@ L'atteignabilité est une propriété du graphe **résolu aujourd'hui**, pas une
|
|||
Trois détails la rendent fiable plutôt que décorative :
|
||||
|
||||
- le code de sortie de `cargo tree` est testé séparément de sa sortie standard — un crate absent sort en 0 avec un stdout vide, tandis qu'un échec de `cargo tree` sort en non-zéro avec un stdout vide lui aussi ; sans cette distinction, une panne de l'outil se lirait comme une preuve d'absence ;
|
||||
- un **canari** (`tar`, dépendance réellement présente) doit être trouvé à chaque exécution, faute de quoi le silence de la boucle ne prouve rien ;
|
||||
- un **canari** (`webkit2gtk`, dépendance réellement présente, et seulement sous Linux ; `tar` jusqu'à #455) doit être trouvé à chaque exécution, faute de quoi le silence de la boucle ne prouve rien ;
|
||||
- `.cargo/**` est ajouté aux `paths` de `check-rust.yml` pour qu'une PR ne touchant que la liste déclenche bien la vérification.
|
||||
|
||||
Le pendant — une entrée devenue *inutile*, que rien ne signale — est couvert par une issue de suivi portant la condition de retrait : #312 l'a joué pour `quick-xml` (et a servi : le déclencheur avait sauté sans que personne le remarque), #319 le fait pour `rsa`. **Toute entrée de la liste doit en avoir une**, sinon le garde-fou ne couvre qu'une moitié du risque.
|
||||
|
|
@ -91,4 +93,4 @@ Le pendant — une entrée devenue *inutile*, que rien ne signale — est couver
|
|||
- [`.forgejo/workflows/check-rust.yml`](../../.forgejo/workflows/check-rust.yml) — étape « Verify suppressed advisories are still unreachable »
|
||||
- [`.forgejo/workflows/audit.yml`](../../.forgejo/workflows/audit.yml) — le gate quotidien bloquant
|
||||
- [ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) — récupération de cours via `reqwest`, l'un des deux chemins qui rendent `rustls-webpki` atteignable
|
||||
- Issues #310 (cette décision), #312 (retrait `quick-xml`, **fait le 2026-07-27**), #313 (`tauri-plugin-deep-link` yanked, fait), #314 (cron jamais déclenché), #315 (smoke-test du cycle de mise à jour), #319 (déclencheur de retrait `rsa`, la dernière entrée restante)
|
||||
- Issues #310 (cette décision), #312 (retrait `quick-xml`, **fait le 2026-07-27**), #313 (`tauri-plugin-deep-link` yanked, fait), #314 (cron jamais déclenché), #315 (smoke-test du cycle de mise à jour), #319 (déclencheur de retrait `rsa`, la dernière entrée restante), #455 (updater sans sa feature `zip`, canari `tar` → `webkit2gtk`)
|
||||
|
|
|
|||
106
docs/adr/0020-identifiant-machine-derive.md
Normal file
106
docs/adr/0020-identifiant-machine-derive.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# ADR 0020 — Identifiant machine dérivé : un hachage à clé fixe remplace l'identifiant du système auprès du serveur de licences
|
||||
|
||||
- Status: **Accepted**
|
||||
- Date: 2026-10-01
|
||||
- Issues: #350 (cette décision), maximus/maximus-api#82 (identifiants bruts déjà stockés par le serveur), maximus/la-compagnie-maximus#184 (données d'activation absentes de la politique de confidentialité), #367 (jeton qui ne représente plus aucune activation, règle 4)
|
||||
- S'appuie sur l'[ADR 0017](0017-feature-gating-par-tier.md), amendement #347 (l'édition suit la clé seule), et sur l'[ADR 0009](0009-proxy-price-fetching-via-maximus-api.md) (cours du marché via maximus-api)
|
||||
|
||||
## Contexte
|
||||
|
||||
Activer une machine la lie à une clé de licence auprès du serveur (`POST /v1/licenses/activate`). Le serveur compte les machines contre la limite de la clé et émet un jeton d'activation, que la route des cours exige. Pour reconnaître une machine, le client envoyait jusqu'à la v0.15.0 l'identifiant du système **tel quel** : `machine_uid::get()`, soit le contenu de `/var/lib/dbus/machine-id` ou de `/etc/machine-id` sous Linux (32 caractères hexadécimaux), et la valeur `MachineGuid` du registre sous Windows (36 caractères).
|
||||
|
||||
Trois constats, relevés par la revue du 2026-09-24 et revérifiés le 2026-10-01 :
|
||||
|
||||
- **machine-id(5) tient cet identifiant pour confidentiel.** Il ne doit pas être exposé sur le réseau, et une application qui a besoin d'un identifiant stable lié à la machine doit le hacher avec une fonction à clé, sous une clé fixe qui lui est propre. D'autres logiciels lisent le même identifiant : l'envoyer tel quel permet de recouper une machine d'un service à l'autre.
|
||||
- **Le serveur le garde et le rediffuse.** maximus-api le stocke avec le courriel de la licence et le nom de l'ordinateur (`license_activations`), le renvoie dans chaque liste de machines et le signe dans le jeton d'activation, valable deux ans.
|
||||
- **La vente est ouverte depuis le 2026-09-29.** Chaque activation faite par une version qui envoie l'identifiant brut en laisse un de plus sur le serveur. La spec d'origine prévoyait pourtant un « hash unique de la machine ».
|
||||
|
||||
Un fait encadre la transition : depuis #347, l'édition ne dépend plus du jeton d'activation. Un jeton que le client refuse coûte l'état « Activée » de la carte Licence et les cours du Bilan, jamais l'édition.
|
||||
|
||||
## Décision
|
||||
|
||||
**Le client n'envoie plus au serveur que `hex(HMAC-SHA256(clé, identifiant du système))`, et reste juste pour une machine activée avant ce changement.** Cinq règles la tiennent.
|
||||
|
||||
### 1. Une dérivation figée
|
||||
|
||||
`derive_machine_id` (`src-tauri/src/commands/license_commands.rs`) calcule `HMAC-SHA256` de l'identifiant du système, rogné de ses blancs, sous la clé ASCII `com.simpl.resultat/license-machine-id/v1`, et rend le résultat en 64 caractères hexadécimaux minuscules.
|
||||
|
||||
- **La clé n'est pas un secret** (le dépôt est public). Elle rend l'identifiant propre à l'application : le même poste donne un autre identifiant à tout autre logiciel qui suivrait la même recette, et l'identifiant du système ne se retrouve pas depuis son dérivé. Elle ne protège pas du sens inverse : qui détient déjà l'identifiant du système d'un poste peut recalculer son dérivé, puisque la clé est publique. La protection porte sur la remontée du dérivé vers l'identifiant du système, pas sur ce rapprochement.
|
||||
- **Ni la clé ni la formule ne changent, jamais.** Chaque activation est enregistrée par le serveur sous un identifiant dérivé de cette clé : en changer rendrait orphelines toutes les activations. Deux vecteurs de référence les épinglent, côté client aujourd'hui et côté serveur le jour où il en aura besoin (maximus/maximus-api#82) :
|
||||
|
||||
| Identifiant du système | Identifiant dérivé |
|
||||
|---|---|
|
||||
| `0123456789abcdef0123456789abcdef` | `4f8817b37072a3a1e6ef014f34c46835ba715b6f2f97afee1fc680d7fc170db1` |
|
||||
| `a1b2c3d4-e5f6-7890-abcd-ef1234567890` | `3b9579ec36b3daf37a2aaab0abe2315fa4a9f07200ae568c5aaa614663037d9d` |
|
||||
|
||||
- **Un identifiant vide est refusé, pas haché.** Toutes les machines sans `machine-id` partageraient sinon le même dérivé, donc la même place sur une licence.
|
||||
- **Les deux formes ne se confondent pas** : 64 caractères pour un dérivé, 32 ou 36 pour un identifiant brut. Une base peut donc être auditée à la longueur.
|
||||
|
||||
### 2. Seule l'activation envoie un identifiant de cette machine
|
||||
|
||||
- `activate_machine` envoie le dérivé. Le jeton que le serveur vient d'émettre doit porter ce même dérivé, sinon la réponse est refusée (`invalid_response`) et rien n'est écrit.
|
||||
- `deactivate_machine` renvoie au serveur l'identifiant de la ligne choisie dans la liste, c'est-à-dire une valeur que le serveur a lui-même fournie. Le client ne calcule rien pour cet appel.
|
||||
- Plus aucune commande ne lit l'identifiant du système pour le remettre au webview : `get_machine_id`, qui n'avait pas d'appelant, est retirée, et `ActivationStatus` ne porte plus d'identifiant. La liste que rend le serveur porte encore celui d'une ligne antérieure à la dérivation, marquée `is_this_machine` quand c'est cette machine, jusqu'à sa réactivation.
|
||||
|
||||
### 3. Une machine déjà activée garde son activation (D1)
|
||||
|
||||
Un jeton valide dont le claim `machine_id` vaut l'identifiant **brut** de cette machine se lit « Activée », comme un jeton qui porte le dérivé. La carte, la liste et les cours d'une machine activée avant ce changement restent donc tels quels, sans appel réseau ajouté. Un jeton expiré, signé par une autre clé ou émis pour une autre machine, sous l'une ou l'autre forme, reste refusé.
|
||||
|
||||
### 4. Le client ne suppose rien du serveur (D3)
|
||||
|
||||
- **« Cette machine » se décide côté Rust.** `list_activated_machines` pose `is_this_machine` sur chaque ligne dont l'identifiant vaut le dérivé **ou** le brut de cette machine. Le client reste juste que le serveur garde ses anciennes lignes telles quelles ou les rehache un jour en place.
|
||||
- **La suppression du jeton local suit la place qu'il représente.** Après une désactivation réussie, `drop_token_of_deactivated_machine_in` supprime `activation.token` dans deux cas. La ligne désactivée est une ligne de cette machine, sous l'une ou l'autre forme : le jeton part avec elle, sauf quand c'est l'ancienne ligne brute et que le jeton vaut pour la ligne dérivée, restée active. La ligne désactivée est celle d'une autre machine : le jeton ne part que s'il a été émis pour cette ligne-là (un jeton repris d'un autre poste).
|
||||
|
||||
| Jeton stocké | Identifiant désactivé | Jeton après |
|
||||
|---|---|---|
|
||||
| brut | brut de cette machine | supprimé |
|
||||
| dérivé | dérivé de cette machine | supprimé |
|
||||
| dérivé | brut de cette machine (ancienne ligne restée active) | gardé |
|
||||
| brut | dérivé de cette machine (ligne rehachée par le serveur) | supprimé |
|
||||
| expiré, illisible ou signé par une autre clé | une ligne de cette machine | supprimé |
|
||||
| valide, émis pour une autre machine | une ligne de cette machine | supprimé |
|
||||
| de cette machine | ligne d'une autre machine | gardé |
|
||||
|
||||
- **Un identifiant du système illisible ne bloque ni la liste ni la désactivation.** Aucune ligne n'est alors marquée, et le jeton ne part que s'il a été émis pour la ligne désactivée. L'état d'activation se lit « Non activée », et l'activation échoue avant tout appel.
|
||||
- **Un jeton qui ne représente plus aucune activation part aussi (#367, 2026-10-04).** À l'ouverture de la liste des machines, `drop_token_of_unlisted_machine_in` supprime le jeton quand aucune ligne de la clé enregistrée ne nomme ce poste, sous l'une ou l'autre forme : jeton d'une autre licence laissé par la v0.15.0, place libérée depuis un autre poste. Rien ne part sans identifiant du système lisible, sur une liste incomplète (une ligne illisible pourrait être celle de ce poste), pour un jeton qui n'active pas ce poste, ni quand le jeton a changé pendant la requête ; une erreur, une clé révoquée ou un 429 ne suppriment rien. Le refus `license_revoked` de la route des cours supprime le jeton qu'il a refusé, à la même condition. Une ligne que le serveur rehacherait (maximus/maximus-api#82) n'est reconnue que si elle vaut exactement le dérivé de la règle 1 : sous une autre forme, une machine activée en brut perdrait son jeton à l'ouverture de la liste, et sa réactivation prendrait une seconde place. Un test l'épingle côté client avec le vecteur de référence.
|
||||
|
||||
### 5. Ce que la transition laisse en place (D2)
|
||||
|
||||
- Un jeton antérieur garde l'identifiant brut dans son claim. Il est envoyé en `Bearer` à chaque appel de cours, donc pour une licence Premium seulement, jusqu'à ce que la machine soit réactivée (« Désactiver » puis « Activer »).
|
||||
- Les lignes que le serveur a déjà reçues gardent leur identifiant brut, y compris après une désactivation, qui marque la ligne inactive sans la supprimer. Le client ne peut rien y faire : c'est l'objet de maximus/maximus-api#82.
|
||||
- **Une seconde ligne à la réactivation.** Quand une ligne brute de cette machine est encore active et que le jeton local manque ou n'est plus valide (expiration au bout de deux ans, dossier de données réinitialisé, autre clé puis retour à la première), « Activer » envoie le dérivé. Le serveur n'a aucune ligne active sous cet identifiant : c'est pour lui une activation nouvelle, là où la v0.15.0 obtenait un rafraîchissement. Elle prend une place de plus, ou rend `machine_limit_reached` si la licence est pleine. Les deux lignes sont alors marquées « Cette machine », et désactiver l'ancienne garde le jeton (règle 4). Ce reste s'éteint si le serveur rehache en place ses lignes actives avant l'expiration des premiers jetons bruts, deux ans après leur émission (maximus/maximus-api#82).
|
||||
- Le critère « aucun identifiant brut ne quitte le poste » est donc borné aux **activations faites avec une version qui dérive**.
|
||||
|
||||
## Condition de retrait de la compatibilité
|
||||
|
||||
L'acceptation de l'identifiant brut, dans le jeton comme dans la liste, se retire quand deux conditions sont réunies :
|
||||
|
||||
1. **deux ans se sont écoulés depuis la dernière émission d'un jeton à claim brut.** Le serveur signe les jetons pour deux ans : passé ce délai, plus aucun jeton brut n'est valide. Cette date ne se déduit pas d'un numéro de version. Une v0.15.0 en édition Gratuite ne se met plus à jour ; son utilisateur peut acheter une clé des mois plus tard, la saisir et cliquer « Activer » avant toute mise à jour, et le serveur signe alors un jeton brut neuf. Seul le serveur connaît la date : aujourd'hui le `last_seen_at` le plus récent de ses lignes brutes, que seuls l'activation et son rafraîchissement écrivent. S'il hache un jour les identifiants à l'arrivée, il devra la consigner à part (maximus/maximus-api#82) ;
|
||||
2. **le serveur ne renvoie plus de ligne brute** (maximus/maximus-api#82).
|
||||
|
||||
Tant que l'une des deux manque, retirer la compatibilité ferait perdre à une machine son état « Activée » ou son marqueur « Cette machine ».
|
||||
|
||||
## Alternatives considérées
|
||||
|
||||
- **Coupure nette : refuser les jetons bruts.** Rejeté : la machine s'afficherait « Non activée », sa réactivation créerait une seconde ligne, et l'ancienne resterait dans la liste comme une machine fantôme qui occupe une place sur la licence. Les cours tomberaient jusqu'à la réactivation.
|
||||
- **Réactivation automatique au démarrage.** Rejeté : deux appels réseau que l'utilisateur n'a pas demandés, à faire tenir dans la limite de cinq requêtes par minute que partagent les routes de licence, et un état bancal si le second échoue.
|
||||
- **Hacher côté serveur seulement.** Rejeté comme réponse unique : l'identifiant brut continuerait de quitter le poste, ce que machine-id(5) demande d'éviter. C'est en revanche le complément attendu du serveur pour les versions antérieures (maximus/maximus-api#82).
|
||||
- **`sd_id128_get_machine_app_specific()` de systemd.** Rejeté : même principe, mais l'appel n'existe que sous Linux et l'application est aussi livrée sous Windows. Une seule dérivation, écrite dans le client, vaut pour les deux.
|
||||
- **Un identifiant d'installation tiré au hasard.** Rejeté : il ne serait plus lié à la machine. Copier le dossier de données copierait l'activation, et une réinstallation consommerait une place de plus.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- **Positif.** Une activation faite par une version qui dérive ne montre plus au serveur qu'un identifiant propre à Simpl'Résultat. Le serveur n'a rien à changer : l'identifiant reste pour lui une chaîne opaque comparée par égalité, et 64 caractères passent son schéma.
|
||||
- **Positif.** La dérivation et sa clé ont un seul point de vérité, tenu par deux vecteurs de référence que le serveur peut reprendre tels quels.
|
||||
- **Coût.** Une branche de compatibilité vit dans le client jusqu'à la condition de retrait ci-dessus.
|
||||
- **Coût.** Les identifiants bruts déjà reçus restent sur le serveur tant que maximus/maximus-api#82 n'est pas traitée, et un jeton antérieur continue de porter le sien.
|
||||
- **À surveiller.** Le dérivé dépend de ce que `machine_uid::get()` renvoie. Les vecteurs épinglent la dérivation, pas la lecture : une version de la crate `machine-uid` qui changerait la forme renvoyée (casse, séparateurs) changerait le dérivé de chaque machine. C'était déjà vrai de l'identifiant brut ; relire ce point avant de monter la crate.
|
||||
- **Portée.** Le nom de l'ordinateur continue d'être envoyé en clair : il sert à reconnaître ses machines dans la liste. La déclaration de ces données relève de maximus/la-compagnie-maximus#184.
|
||||
|
||||
## Liens
|
||||
|
||||
- `src-tauri/src/commands/license_commands.rs` — `derive_machine_id`, `MachineIds`, les règles de jeton, de liste et de désactivation, et leurs tests
|
||||
- `src/components/settings/LicenseCard.tsx` — marqueur « Cette machine » lu depuis `is_this_machine`
|
||||
- [ADR 0017](0017-feature-gating-par-tier.md) — amendement #347 : l'édition suit la clé seule, le jeton ne sert qu'à l'état « Activée » et aux cours
|
||||
- `docs/architecture.md` — section `license_commands.rs`
|
||||
- Issues #350, maximus/maximus-api#82, maximus/la-compagnie-maximus#184
|
||||
93
docs/adr/0021-migrations-profils-secondaires.md
Normal file
93
docs/adr/0021-migrations-profils-secondaires.md
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# ADR 0021 — Les profils secondaires rattrapent leurs migrations à l'ouverture
|
||||
|
||||
- Status: **Accepted**
|
||||
- Date: 2026-10-06
|
||||
- Issues: #390
|
||||
- Modifie l'[ADR 0003](0003-sqlx-migrations.md) (le schéma consolidé crée un profil, il ne le tient pas à jour) et l'[ADR 0005](0005-multi-profile-db.md) (« les migrations s'appliquent indépendamment à chaque base » était faux pour les profils secondaires)
|
||||
|
||||
## Contexte
|
||||
|
||||
Le constat de départ est un bug d'onboarding : classer une transaction à la main ne cochait pas la case 5 de la liste de démarrage. `updateTransactionCategory` liait le booléen JS `true`, que tauri-plugin-sql 2.3.2 transmet à sqlx comme une valeur JSON, donc stockée en texte `'true'`. La liste compte `is_manually_categorized = 1` et ne trouvait rien. Corriger les valeurs déjà en base par une migration v18 a fait apparaître deux défauts plus anciens.
|
||||
|
||||
**Les profils secondaires ne reçoivent aucune migration.** Le plugin associe la liste des migrations à une URL (`add_migrations("sqlite:simpl_resultat.db", …)`) et la retire au premier `Database.load`. Un profil secondaire (`profile_<id>.db`, depuis la v0.3.0) est créé par `initializeNewProfileDb` avec le schéma consolidé de la version qui le crée, puis n'en reçoit plus aucune. Un profil secondaire créé avant la v0.15.0 n'a donc pas les colonnes de format de `import_sources` (v17), et un profil créé avant la v0.10.0 n'a ni `balance_securities` ni `balance_accounts.kind`. Les commentaires de `consolidated_schema.sql` et de `get_new_profile_init_sql`, ainsi que l'ADR 0005, affirmaient le contraire.
|
||||
|
||||
**Trois commandes cherchaient les bases au mauvais endroit sous Linux.** Le plugin range les bases sous `app_config_dir()` (`wrapper.rs` : `DbPool::connect` puis `path_mapper`). `repair_migrations`, `delete_profile_db` et `compute_account_return` les cherchaient sous `app_data_dir()`, là où vivent `profiles.json` et les fichiers de licence. C'est le même dossier sous Windows et macOS, pas sous Linux (`~/.config/com.simpl.resultat` et `~/.local/share/com.simpl.resultat`). Sous Linux, `repair_migrations` ne faisait donc rien, supprimer un profil laissait sa base sur le disque, et le rendement d'un compte du Bilan échouait.
|
||||
|
||||
## Décision
|
||||
|
||||
**À chaque ouverture d'un profil, avant `Database.load`, la commande `prepare_profile_db` amène un profil secondaire à la dernière migration, avec la même liste que le profil par défaut, et répare les drapeaux texte de tous les profils.** Neuf règles la tiennent ; le code est dans `src-tauri/src/database/profile_db.rs`.
|
||||
|
||||
### 1. Une seule liste de migrations
|
||||
|
||||
`migrations()` (lib.rs) sert le plugin et le rattrapage. Le test `migration_checksums_match_the_released_ones` épingle le SHA-384 de chaque version, calculé avant que la liste soit sortie de `run()`. L'historique d'un profil secondaire vit dans la même table `_sqlx_migrations` que celui du profil par défaut, au format sqlx : même DDL, même checksum.
|
||||
|
||||
### 2. Un nouveau profil naît avec son historique
|
||||
|
||||
`get_new_profile_init_sql` renvoie le schéma consolidé, puis `history_sql`, qui inscrit toutes les versions avec leur checksum. Le plugin exécute les deux dans le même flux. Un profil créé depuis #390 n'a donc jamais rien à déduire : les versions suivantes l'atteignent par le rattrapage.
|
||||
|
||||
### 3. Un profil sans historique : un plancher déduit du schéma
|
||||
|
||||
Les profils créés avant #390 n'ont pas d'historique. Ils forment un ensemble fermé, un état par schéma consolidé qu'une version publiée a livré, et chaque état est conservé en fixture sous `src-tauri/src/database/fixtures/` :
|
||||
|
||||
| État | Versions qui l'ont créé | Plancher |
|
||||
|---|---|---|
|
||||
| A1 | v0.3.0 à v0.8.3, sauf v0.4.0 | 6 |
|
||||
| A2 | v0.4.0 (seed à trois niveaux, retiré en v0.4.1) | 6 |
|
||||
| B | v0.8.4 | 8 |
|
||||
| C et D | v0.9.0, v0.9.1 (même schéma ; v11 ne touche que des données) | 10 |
|
||||
| E | v0.10.0 à v0.14.0 | 16 |
|
||||
| F | v0.15.0 à v0.17.x | 17 |
|
||||
|
||||
`infer_floor` reconnaît l'état par la dernière colonne ou table ajoutée, en partant de la plus récente. Le plancher vient du schéma, jamais de l'historique : une première écriture interrompue ne peut donc pas faire passer une version inférieure pour une version à appliquer. C'est ce qui interdit de rejouer le seed v2 (`DELETE FROM categories`) sur une base remplie. Un schéma qu'aucun marqueur ne reconnaît n'est pas touché. Aucun marqueur ne s'ajoute après la v17, puisqu'aucune base sans historique ne peut porter une version postérieure.
|
||||
|
||||
### 4. Une copie avant la première écriture
|
||||
|
||||
Quand une version reste à appliquer, la base est d'abord copiée par `VACUUM INTO` vers `<dossier des bases>/migration-backups/<profil>-before-v<N>.db`. Le chemin est lié, jamais formaté dans le SQL, ce qui tient un dossier comme `C:\Users\L'Heureux`. La copie est écrite en `.tmp`, synchronisée sur disque, puis renommée : un fichier final existant est donc complet. Une copie qui échoue arrête tout avant la moindre écriture. La copie est supprimée avec le profil.
|
||||
|
||||
**Rétention (#395, décision de Max du 2026-10-10) : une seule copie par profil, la plus récente.** La copie est refaite à chaque rattrapage, même quand un fichier du même nom existe : laissé par un rattrapage qui s'est arrêté, il précède les données saisies depuis. Le renommage remplace ce fichier, qui n'est jamais supprimé d'abord. Après un rattrapage **réussi**, et seulement après le renommage, les autres `<profil>-before-v*.db` (et `.db.tmp`) de ce profil sont supprimés (`remove_backups(dossier, profil, Some(copie))`, au mieux : une copie restée ne nuit pas). Rien n'est supprimé quand le rattrapage s'arrête, quand la copie échoue, ni quand rien n'est à appliquer. Coût accepté : tant qu'un rattrapage échoue, chaque ouverture refait un `VACUUM INTO`.
|
||||
|
||||
Elle reste dans le dossier des bases, pas dans `~/Documents/Simpl-Resultat/backups/` que la sauvegarde manuelle utilise : ce dossier est souvent synchronisé vers un service en ligne (OneDrive sous Windows), et une copie automatique en clair d'une base financière n'a rien à y faire.
|
||||
|
||||
### 5. Des écritures atomiques
|
||||
|
||||
L'historique, soit la table et chaque version jusqu'au plancher, est écrit en une transaction. Chaque version à appliquer s'exécute ensuite dans sa propre transaction. La première erreur arrête le rattrapage et les versions déjà appliquées restent inscrites.
|
||||
|
||||
### 6. Les clés étrangères restent actives
|
||||
|
||||
Comme sqlx pour le profil par défaut. Une version qui échoue sur une clé étrangère est reprise une fois sans elles. Les lignes qu'elle laisse sans parent repassent à NULL quand leur clé déclare `ON DELETE SET NULL` ; toute autre violation annule la version. Le cas réel est la v7, qui ajoute trois sous-catégories sous la catégorie 31 : un profil A dont les catégories viennent d'une restauration plus récente n'a plus de catégorie 31, et les trois sous-catégories deviennent des catégories racines. Couper les clés pour tout le rattrapage aurait laissé ces parents introuvables, et la restauration d'une sauvegarde de ce profil aurait échoué.
|
||||
|
||||
### 7. L'échec reste ouvert
|
||||
|
||||
Un rattrapage interrompu est écrit au Journal et le profil s'ouvre comme avant #390. Les messages nomment des fichiers, jamais le dossier : il contient le nom d'utilisateur du système, et une ligne d'erreur du Journal peut être jointe à une rétroaction (Loi 25). Un rattrapage qui s'arrête après sa copie la nomme quand même : `catch_up_backup` (nom de fichier seul) est rempli dès que la copie existe, et la ligne d'erreur du Journal finit par « copy of the previous state kept as … » (#395). Côté TypeScript, `withoutFolders` réduit chaque chemin d'une ligne d'erreur, message de repli compris, à son dernier segment (le nom de fichier). Un chemin part d'un début reconnu (lecteur `C:\`, `/`, `~/`, `./`, partage `\\`, ou le `<profile folder>` de `scrub`) et court jusqu'à son dernier séparateur, espaces compris, pour qu'un dossier comme `C:\Users\Jean Dupont\` ou `/home/marie claire/` ne laisse aucun mot derrière lui ; il s'arrête à un saut de ligne, un guillemet double, une apostrophe inverse, `;`, `|`, une virgule ou un deux-points suivis d'un blanc, une parenthèse fermante qui n'est pas suivie d'un séparateur (`Program Files (x86)\` continue), ou un blanc suivi d'un autre chemin. Une apostrophe placée juste avant le chemin reste autour du nom de fichier (`'C:\Users\Bob\x.db'` donne `'x.db'`). Quand rien ne l'arrête, la règle est prudente : elle retire le texte qui suit jusqu'au dernier séparateur de la ligne plutôt que de laisser passer un dossier. Limite : un chemin relatif sans début reconnu (`dossier/sous dossier/x.db`) n'a que ses séparateurs remplacés par `-` ; il ne part pas du dossier personnel, donc n'en contient pas le nom. Tout autre séparateur devient `-` : aucune ligne d'erreur ne contient `/` ni `\`.
|
||||
|
||||
### 8. Un seul dossier des bases
|
||||
|
||||
`profile_db_path` résout `app_config_dir()`, le dossier du plugin, pour les quatre commandes qui ouvrent une base. Il n'accepte que les deux formes de nom que l'application a jamais données : `simpl_resultat.db` et `profile_<hex>.db`.
|
||||
|
||||
### 9. Les drapeaux texte sont réparés partout
|
||||
|
||||
À chaque ouverture, dans tous les profils, `'true'` devient 1 et tout autre texte 0. L'opération est idempotente. Côté TypeScript, `toSqlFlag` empêche d'écrire de nouveau un booléen, y compris à la restauration d'une sauvegarde qui porte encore `"true"`.
|
||||
|
||||
## Conséquences
|
||||
|
||||
### Positives
|
||||
|
||||
- Un profil secondaire ancien retrouve le schéma courant à sa prochaine ouverture, et chaque migration future l'atteint.
|
||||
- Sous Linux, supprimer un profil supprime enfin sa base, ses fichiers annexes et ses copies.
|
||||
- Les règles sont éprouvées sur les sept états réels, avec des données d'utilisateur : comptes du Bilan convertis par v12 à v16, source d'import complétée par v17, drapeau réparé.
|
||||
|
||||
### Négatives et points de vigilance
|
||||
|
||||
- **Une correction de données livrée seulement en migration n'atteint pas un nouveau profil**, qui naît avec l'historique complet. Elle doit aussi entrer dans `consolidated_schema.sql` (règle `.claude/rules/sql-migrations.md`). Le test qui compare chaque état rattrapé à un profil neuf ne regarde que le schéma.
|
||||
- **Deux moteurs appliquent les mêmes migrations** : sqlx pour le profil par défaut, rusqlite pour les autres. Le SQL est le même et les clés étrangères sont actives des deux côtés. La reprise sans clés étrangères n'existe que du côté rusqlite.
|
||||
- **La commande est synchrone.** Tauri l'exécute sur le fil principal, ce qui sérialise deux ouvertures du même profil (React StrictMode monte deux fois en développement). L'interface attend pendant un rattrapage, une fois par profil ancien, et la durée est écrite au Journal.
|
||||
- **Le délai de démarrage couvre le rattrapage du profil actif** (#395). `App.tsx` affiche la page d'erreur si l'ouverture dépasse `STARTUP_TIMEOUT_MS` (10 s, `src/utils/startupConnection.ts`), délai qui couvre `repair_migrations`, `prepare_profile_db` et `Database.load`. Mesure (test `#[ignore]` `a_large_profile_catch_up_duration`) : un profil au plus bas plancher reconnu (6, schéma v0.3.0) avec 100 001 transactions (17 Mo, 10 001 drapeaux texte), rattrapé de la v7 à la v17 avec la copie `VACUUM INTO`, le `foreign_key_check` final et la réparation des drapeaux, prend **306 ms en release** et 422 ms en debug, le 2026-10-12, sur le ThinkPad T490 de Max (Intel Core i5-8365U, 4 cœurs / 8 fils, 1,6 à 4,1 GHz, 16 Go, SSD NVMe), sous Linux. Règle fixée en planification : au-delà de 3 s, le délai passait à 30 s ; la mesure étant sous 3 s, il reste à 10 s. Risque résiduel : un disque lent (HDD, antivirus Windows qui inspecte la copie), une base bien plus grosse ou une machine bien plus faible peuvent dépasser 10 s. La page d'erreur s'affiche alors, mais l'ouverture continue et, depuis #395, l'application s'affiche dès qu'elle aboutit (`setStartupError(null)` au succès). Au-delà de 15 s, l'alerte « profile opening » du verrou de `db.ts` (#408) s'écrit aussi au Journal, ce qui est attendu.
|
||||
- **Le test qui distingue les deux dossiers ne voit l'écart que sous Linux**, la plateforme de la CI. La vérification à la main sous Linux reste due.
|
||||
- **Les profils supprimés sous Linux avant ce correctif** ont laissé leur base dans `~/.config/com.simpl.resultat/`. Rien ne supprime automatiquement un fichier que `profiles.json` ne cite pas : un `profiles.json` abîmé coûterait alors de vrais profils.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Une migration v18 de données**, proposée par le corps de #390 : elle n'aurait réparé que le profil par défaut.
|
||||
- **Enregistrer chaque profil auprès du plugin au démarrage** (`add_migrations` par URL dans `setup`) : l'état des migrations du plugin est privé et figé au démarrage, un profil créé en cours de session n'y figurerait pas, et sqlx aurait rejoué v1 et v2 sur une base sans historique.
|
||||
- **Un rattrapage côté TypeScript** : le plugin n'expose aucune transaction (voir `db.ts`), et ni `VACUUM INTO` ni les clés étrangères ne seraient testables hors de Tauri.
|
||||
- **Des clés étrangères coupées pendant tout le rattrapage** : les actions `ON DELETE` ne joueraient plus comme sur le profil par défaut, et les lignes sans parent feraient échouer la restauration d'une sauvegarde de ce profil.
|
||||
94
docs/adr/0022-canal-evenements-donnees.md
Normal file
94
docs/adr/0022-canal-evenements-donnees.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# ADR 0022 — Canal d'événements de données : les écritures signalent, le parcours de démarrage relit
|
||||
|
||||
- Status: **Accepted**
|
||||
- Date: 2026-10-08
|
||||
- Issues: #396 (milestone `planned-2026-10-06-onboarding-parcours-guide`, phase 2 de l'onboarding, #391) ; amendé par #425 (étapes de visite, §2, §4 et §5)
|
||||
- S'appuie sur l'[ADR 0002](0002-useReducer-vs-redux.md) (un `useReducer` par domaine, pas de store global) et l'[ADR 0016](0016-persistance-etat-ui-par-profil.md) (préférences par profil dans `user_preferences`)
|
||||
|
||||
## Contexte
|
||||
|
||||
La phase 2 de l'onboarding fait suivre l'utilisateur par le parcours de démarrage : un bandeau sur chaque page et, sur la page de l'étape en cours, une carte qui voit l'étape se cocher pendant que la page est ouverte. Il faut donc que l'état du parcours soit partagé par toute l'application et relu après chaque écriture qui le concerne.
|
||||
|
||||
Rien ne le permettait. Les pages lisent leurs données à leur montage, et aucun mécanisme ne relie une écriture à un écran qui en dépend. La liste de démarrage de la phase 1 (#351) vivait dans `DashboardPage`, lue une fois au montage : choisir un dossier d'import, importer un fichier ou classer une transaction ne la mettait à jour qu'au retour sur le tableau de bord.
|
||||
|
||||
Le parcours se calcule depuis la base : cinq compteurs lus par `COUNTERS_SQL` (`onboardingService.ts`), sur `import_sources`, `imported_files`, `transactions` et `budget_entries`, plus la préférence `import_folder`. Ces données s'écrivent depuis plusieurs pages et plusieurs services, et une restauration de sauvegarde depuis Paramètres → Données les remplace toutes.
|
||||
|
||||
## Décision
|
||||
|
||||
**Un canal d'événements de données, `src/services/dataEvents.ts` : les écritures qui peuvent changer un compteur appellent `notifyDataChanged()` une fois réussies, et le fournisseur du parcours, abonné par `subscribeDataChanged`, relit son état.** Le module n'importe pas React ; il suit le modèle de `logService.subscribe`.
|
||||
|
||||
### 1. Portée
|
||||
|
||||
Le message dit seulement : « des données que lisent des compteurs ont peut-être changé ». Il ne porte rien, ni table, ni identifiant, ni valeur : l'abonné relit ce dont il a besoin, en une requête. Le nom est côté données, parce que la couche de données ne sait pas qui écoute : aucun service n'importe de hook ni de contexte.
|
||||
|
||||
`notifyDataChanged` est synchrone et ne lève jamais. Un abonné qui lève est écrit au Journal (`Data events: a subscriber failed`) et les autres sont appelés quand même : une écriture réussie n'est jamais présentée comme un échec à cause d'un abonné.
|
||||
|
||||
### 2. Les écritures qui notifient
|
||||
|
||||
La liste est construite depuis les tables que lit `COUNTERS_SQL`, plus le dossier d'import. Chaque notification part **après une écriture réussie** ; une écriture qui lève ne notifie pas. Une fonction qui écrit en plusieurs requêtes notifie une fois, après la dernière.
|
||||
|
||||
| Service | Écritures | Moment |
|
||||
|---|---|---|
|
||||
| `userPreferenceService.ts` | `setImportFolder` | après l'écriture de la préférence (`setPreference` seul ne notifie pas) |
|
||||
| `importSourceService.ts` | `createSource`, `deleteSource` | après l'écriture ; `createSource` notifie avant la relecture qui suit un conflit de nom, qui ne fait que lire |
|
||||
| `importedFileService.ts` | `createImportedFile` (insertion comme mise à jour), `updateFileStatus`, `deleteImportWithTransactions`, `deleteAllImportsWithTransactions` | après l'écriture ; pour les suppressions, après la dernière (source orpheline comprise). Un refus du contrôle préalable (transactions liées au Bilan) ne notifie pas |
|
||||
| `transactionService.ts` | `insertBatch`, `updateTransactionCategory` | `insertBatch` une fois, après la dernière insertion |
|
||||
| `categorizationService.ts` | `applyKeywordWithReassignment` | après le `COMMIT`, une fois le verrou de `db.ts` relâché ; un `ROLLBACK` relance l'erreur et ne notifie pas |
|
||||
| `budgetService.ts` | `upsertBudgetEntry`, `deleteBudgetEntry`, `upsertBudgetEntriesForYear`, `applyTemplate` | après l'écriture ; `upsertBudgetEntriesForYear` après les douze mois, `applyTemplate` une fois après la dernière entrée du modèle |
|
||||
| `dataExportService.ts` | `runRestore` | après le `COMMIT`, une fois le verrou relâché : un seul point pour les trois modes de restauration |
|
||||
| `useImportWizard.ts` | fin d'`executeImport` | une notification de plus, en succès comme en échec (`finally`), pour que la dernière relecture voie l'état final de l'import |
|
||||
|
||||
Cette liste ne doit pas rétrécir. Sans `insertBatch` ni `updateFileStatus`, la dernière relecture d'un import partait avant l'insertion des transactions, et le parcours restait sur un état intermédiaire.
|
||||
|
||||
Ne notifient pas :
|
||||
|
||||
- `updateSource`, qui ne change aucun compte ; les notes d'une transaction ; les modèles de budget ; toute écriture d'une table que ne lit aucun compteur ;
|
||||
- `categoryMigrationService.applyMigration`, qui réécrit `budget_entries` (`DELETE` des mois en double sous la catégorie cible, puis `UPDATE` de la catégorie) : il ne tourne que sur sa propre page, la migration des catégories, et la relecture qui suit tout changement de route rattrape le compteur ;
|
||||
- `transactionService.deleteTransaction` (`DELETE FROM transactions`), sans appelant aujourd'hui : son premier appelant l'ajoute au tableau, avec son test ;
|
||||
- les préférences du parcours lui-même (`hideChecklist`, `showChecklist`, `skipStep`, `resumeStep`, `acknowledgeStep`, et `initializeGuidedPath`, la règle D9, et `initializeVisitSteps`, l'initialisation des étapes de visite, #425). Le fournisseur en est le seul écrivain et met son état à jour lui-même.
|
||||
|
||||
**Règle pour la suite** : une nouvelle écriture d'une table que lit `COUNTERS_SQL` s'ajoute à ce tableau et notifie, avec un test qui prouve la notification après un succès et son absence après un échec. Une écriture faite dans `withTransaction` notifie après la fin de `withTransaction`, jamais à l'intérieur du rappel : le `COMMIT` est fait et le verrou relâché.
|
||||
|
||||
### 3. Une attente de 150 ms côté abonné
|
||||
|
||||
L'émetteur n'attend rien. L'abonné attend 150 ms sans nouvel événement avant de relire (`followDataChanges`, `useOnboardingChecklist.ts`) : chaque événement relance l'attente, et une rafale, comme les écritures d'un import (source, fichiers, insertions, fin), coûte une seule relecture. Le démontage de l'abonné désabonne et annule une relecture en attente.
|
||||
|
||||
### 4. Les réponses périmées sont ignorées
|
||||
|
||||
Les chargements sont numérotés, et le reducer n'applique que la réponse du dernier lancé : une réponse lente ne remplace jamais une plus récente. Une action locale (`HIDE`, `SHOW`, `SKIP`, `RESUME`, `ACKNOWLEDGE`) prime sur le snapshot d'un chargement parti avant elle, lu avant son écriture : c'est la course entre « Masquer » et un rafraîchissement relevée en #384. Une relecture qui échoue après un premier succès garde l'état affiché, et seul le passage en échec est écrit au Journal, pour ne pas chasser une vraie cause hors des dernières lignes jointes à une rétroaction.
|
||||
|
||||
### 5. Abonnés admis
|
||||
|
||||
Un abonné :
|
||||
|
||||
- relit, et n'écrit rien en réponse : une écriture déclenchée par une notification ouvrirait une boucle ;
|
||||
- regroupe les rafales par une attente, et ignore les réponses périmées ;
|
||||
- se désabonne à son démontage ;
|
||||
- ne fait rien de coûteux dans l'appel lui-même, qui est synchrone : il arme un minuteur.
|
||||
|
||||
Une exception admise, comme la règle D9 : le cochage d'office des étapes de visite (#425). Il n'est pas une réponse à une notification, mais une écriture unique par profil (`initializeVisitSteps`), lancée après le premier chargement réussi où le droit au Budget est connu et le drapeau `onboarding_visit_steps_initialized` absent, gardée par une promesse partagée contre le double montage de StrictMode, et qui relit elle-même une fois écrite. Elle ne notifie pas, et ses écritures sont idempotentes, sans transaction : un arrêt entre deux refait le même calcul à l'ouverture suivante.
|
||||
|
||||
Aujourd'hui, un seul abonné : le fournisseur du parcours de démarrage (`OnboardingProvider`, par `useOnboardingChecklist`). Un abonné qui aurait besoin d'autres tables élargit la portée du canal : il amende cet ADR, avec la liste des écritures concernées et leurs tests.
|
||||
|
||||
## Conséquences
|
||||
|
||||
### Positives
|
||||
|
||||
- Le parcours suit l'utilisateur en direct, sur toutes les pages, sans retour au tableau de bord.
|
||||
- Une seule liste d'écritures, au même endroit que le code qui écrit, et gardée par un test par service.
|
||||
- Les services restent ignorants de l'interface : ils n'importent qu'un module sans React.
|
||||
|
||||
### Négatives et points de vigilance
|
||||
|
||||
- **La liste est manuelle.** Une écriture oubliée ne coche pas l'étape en direct ; la relecture au changement de page la rattrape.
|
||||
- **Une écriture en plusieurs requêtes sans transaction qui échoue à mi-chemin ne notifie pas**, alors que des lignes ont pu changer (par exemple `insertBatch` après quelques insertions). La notification finale d'`executeImport`, ou la relecture suivante, rattrape.
|
||||
- **La notification est synchrone.** Un abonné lent ralentirait l'écriture qui notifie : la règle des abonnés admis l'interdit.
|
||||
- **Le canal est global à la fenêtre.** Un changement de profil remonte `AppShell` (`BrowserRouter key={refreshKey}`) ; le fournisseur du profil précédent se désabonne alors et annule sa relecture en attente.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Relire la base à intervalle fixe** : des requêtes sans raison sur le verrou unique de `db.ts`, et une étape qui se coche avec un retard.
|
||||
- **Un store global de données** : contraire à l'[ADR 0002](0002-useReducer-vs-redux.md), et un store ne sait pas mieux qu'un canal quand la base change.
|
||||
- **Notifier depuis `db.ts` à chaque `execute`** : un import écrit une requête par transaction, et le canal ne saurait pas distinguer une table d'une autre ; il deviendrait bavard et sans portée.
|
||||
- **Des événements typés (table, identifiant)** : plus de couplage, et aucun abonné n'en a besoin, puisque les compteurs se relisent en une requête.
|
||||
- **Faire appeler `refresh()` par les hooks des pages** : chaque page dépendrait du parcours, et une écriture faite ailleurs, comme une restauration depuis Paramètres → Données, serait oubliée.
|
||||
161
docs/adr/0023-windows-sys-pinning.md
Normal file
161
docs/adr/0023-windows-sys-pinning.md
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
# ADR 0023 — Épinglage de `windows-sys` 0.61.2 : garde-fou CI sur la version résolue, condition de sortie écrite
|
||||
|
||||
- Status: **Accepted**
|
||||
- Date: 2026-10-10
|
||||
- Issues: #345 (cette décision), PR #344 (deuxième dérive, contournée en n'appliquant que la strophe `h2` à la main), #348 (mesure `rustls` : la couche TLS en général ne déclenche pas la dérive)
|
||||
- S'appuie sur l'[ADR 0018](0018-suppression-advisories-non-atteignables.md) : même motif de garde-fou (chaque vérification loggée, canari, code de sortie de l'outil testé à part de sa sortie)
|
||||
|
||||
## Contexte
|
||||
|
||||
Sept crates du fichier de verrouillage déclarent `windows-sys` par une borne qui couvre plusieurs mineures :
|
||||
|
||||
| crate | exigence déclarée sur `windows-sys` |
|
||||
|---|---|
|
||||
| `colored` 3.1.1, `winapi-util` 0.1.11 | `>=0.48, <=0.61` |
|
||||
| `errno` 0.3.14, `rustix` 1.1.3, `tempfile` 3.24.0 | `>=0.52, <0.62` |
|
||||
| `rustls-platform-verifier` 0.6.2 | `>=0.52.0, <0.62.0` |
|
||||
| `dirs-sys` 0.5.0 | `>=0.59.0` |
|
||||
|
||||
Ce sont les seuls du graphe dans ce cas (relevé par `cargo metadata` le 2026-10-10). Toutes les autres déclarations sont des `^0.x`, qui n'admettent qu'une mineure, donc une seule des versions présentes : elles ne peuvent pas dériver. Le lock contient six versions de `windows-sys`, toutes cibles confondues (0.45.0, 0.48.0, 0.52.0, 0.59.0, 0.60.2, 0.61.2), et les sept crates peuvent pointer n'importe laquelle de celles que leur borne admet, sans ajouter de paquet au lock et sans violer aucun contrat.
|
||||
|
||||
Les sept pointent **`0.61.2`**. C'est l'état du tag v0.14.0 (2026-07-18), dont la release (run 322) a traversé un vrai build Windows NSIS, et celui de chaque release depuis : v0.15.0 (run 402), v0.16.0 (run 451), v0.17.0 (run 491), v0.17.1 (run 508), vérifié le 2026-10-10 en rejouant le garde ci-dessous sur le lock de chaque tag. L'état dérivé n'a traversé aucun build Windows : aucune release n'a eu lieu pendant la seule fenêtre où `main` l'a porté, de #310 (2026-07-27) à la PR #321 (2026-08-15).
|
||||
|
||||
### La contrainte n'était tenue par rien
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| #310 (2026-07-27) | `cargo update -p rustls-webpki -p tar` fait descendre les sept arêtes ; vu, et jugé inoffensif parce que sans effet sur Linux |
|
||||
| PR #321 (2026-08-15) | la revue les remonte à `0.61.2`, crate par crate contre le tag v0.14.0 |
|
||||
| PR #344 (2026-09-04) | `cargo update -p h2` recommence ; contourné en n'appliquant que la strophe `h2` à la main |
|
||||
| #348 (2026-09-21) | `cargo update -p rustls --precise 0.23.45` ne bouge rien, mais il a fallu remesurer pour le savoir |
|
||||
|
||||
Hors du lock, la contrainte ne vivait que dans de la prose (`STATE.md`, des messages de commit) : la prochaine mise à jour la régresserait, et il faudrait qu'un relecteur reconnaisse sept noms de crates pour le voir. C'est le mode d'échec que l'ADR 0018 traite pour les suppressions d'advisories : une décision qui ne vit que dans de la prose n'est pas une décision tenue.
|
||||
|
||||
### Le déclencheur, mesuré
|
||||
|
||||
| commande, sur une copie du lock | effet sur les sept arêtes | mesure |
|
||||
|---|---|---|
|
||||
| `cargo update -p anyhow` | aucun | 2026-09-04 |
|
||||
| `cargo update -p rustls --precise 0.23.45` | aucun ; le `diff` de toutes les lignes `windows-sys` du lock est vide | 2026-09-21 (#348) |
|
||||
| `cargo update -p hyper` | les sept descendent : `colored` et `winapi-util` vers 0.48.0, `errno`, `rustix`, `tempfile` et `rustls-platform-verifier` vers 0.52.0, `dirs-sys` vers 0.59.0 | 2026-09-04, refaite le 2026-10-10 (`hyper` 1.8.1 → 1.12.0) |
|
||||
|
||||
Dans cette mesure, chaque arête est descendue vers la plus basse version déjà présente que sa borne admet, et la dérive de #310 (commit `f4b09b0`) a la même répartition : deux arêtes vers 0.48.0, quatre vers 0.52.0, une vers 0.59.0. `hyper` et ce qu'il tire (`h2` dans la PR #344) la déclenchent, la couche TLS en général non : `rustls`, en son cœur, ne déplace rien. Mais `rustls-webpki` et `tar` l'ont déclenchée en #310. Aucune règle simple ne dit quelle mise à jour la provoque, et la dernière qu'on a formulée (« tout bump du sous-arbre réseau/TLS ») a été démentie par #348. C'est donc la version résolue qu'il faut garder, pas une liste de déclencheurs.
|
||||
|
||||
**Et cargo n'en dit rien.** Le 2026-10-10, `cargo update -p hyper` annonce :
|
||||
|
||||
```
|
||||
Locking 2 packages to latest compatible versions
|
||||
Updating hyper v1.8.1 -> v1.12.0
|
||||
Updating want v0.3.1 -> v0.3.2
|
||||
```
|
||||
|
||||
pendant qu'il réécrit aussi les sept arêtes. L'ensemble des paquets ne bouge pas (666 avant et après) ; seules les arêtes changent, et cargo ne rapporte que les paquets. Or les deux dérives passées sont venues avec des correctifs d'advisories RustSec (`rustls-webpki` et `tar` en #310, `h2` dans la PR #344) : la dérive arrive avec les changements les plus pressés et les moins relus.
|
||||
|
||||
### Ce que coûte la dérive
|
||||
|
||||
Pas un risque de correction : les deux états sont dans les bornes déclarées par les sept auteurs. Le coût est de taille et de temps de compilation, et il tombe sur le build Windows, que personne ne mesurait :
|
||||
|
||||
| état | versions de `windows-sys` compilées pour `x86_64-pc-windows-msvc` |
|
||||
|---|---|
|
||||
| épinglé (`0.61.2`) | **3** : 0.59.0, 0.60.2, 0.61.2 |
|
||||
| dérivé | **5** : les mêmes, plus 0.48.0 et 0.52.0 |
|
||||
|
||||
Mesuré par `cargo tree --target x86_64-pc-windows-msvc -e normal,build`, refait le 2026-10-10 sur le lock de `main` et sur sa copie dérivée par `cargo update -p hyper`. Deux crates `windows-sys` complets de plus dans chaque build Windows, ajoutés en effet de bord, dans une configuration qu'aucun build Windows n'a traversée.
|
||||
|
||||
## Décision
|
||||
|
||||
**Un garde-fou CI bloquant sur la version résolue, et cet ADR pour en porter la raison et la sortie.**
|
||||
|
||||
L'étape `Verify windows-sys pinning` de `check-rust.yml`, placée après `cargo check` à côté du garde des suppressions, lit `src-tauri/Cargo.lock` avec `awk` et applique quatre règles. `awk` (mawk) fait partie de l'image de base `ubuntu:22.04`, alors que `python3` n'est présent dans ce conteneur que comme dépendance transitive des paquets `apt` installés à l'étape précédente (journal du run 517), ce que rien ne garantit :
|
||||
|
||||
1. **Chaque arête des sept crates.** Pour `colored`, `dirs-sys`, `errno`, `rustix`, `rustls-platform-verifier`, `tempfile` et `winapi-util`, chaque bloc `[[package]]` de ce nom doit porter au moins une arête `windows-sys`, et chacune doit nommer `0.61.2`. L'absence de bloc pour l'un de ces noms fait échouer l'étape (crate renommé ou sorti du graphe). Un suffixe de source sur une arête (` (registry+…)`) est toléré.
|
||||
2. **Arête nue.** Cargo n'écrit `"windows-sys 0.61.2"` que tant que plusieurs versions coexistent ; quand il n'en reste qu'une, il écrit `"windows-sys"`. Une arête nue ne passe que si le lock ne contient qu'un seul paquet `windows-sys`, en `0.61.2`.
|
||||
3. **Ensemble des versions.** Les versions de `windows-sys` du lock, toutes cibles confondues et doublons compris, doivent égaler exactement `{0.45.0, 0.48.0, 0.52.0, 0.59.0, 0.60.2, 0.61.2}` (état du 2026-10-10). Une version qui apparaît ou disparaît fait échouer l'étape avec un renvoi à cet ADR : c'est soit le coût réel (une version de plus), soit le début de la sortie, et dans les deux cas une décision.
|
||||
4. **Canari.** La boucle doit avoir lu et vérifié une arête de `tempfile`, crate connu pour en porter une, faute de quoi son silence ne prouve rien et l'étape échoue.
|
||||
|
||||
Comme dans l'ADR 0018, chaque vérification est loggée, succès compris (`ok: rustix 1.1.3 -> windows-sys 0.61.2`), puis un bilan (`windows-sys pinning: 9 checks, exit 0`), et le code de sortie d'`awk` est testé à part de sa sortie. Une sortie vide échoue déjà (ensemble des versions, blocs introuvables, canari) ; ce test attrape seul un `awk` qui échoue après avoir tout imprimé, et sans lui le garde sortirait en 0 (éprouvé à la revue de la PR #415). Le garde n'appelle pas cargo, donc ni l'index chaud ni `--locked` ne le concernent ; il lit le lock tel que `cargo check` l'a laissé.
|
||||
|
||||
La liste gardée, la version épinglée et l'ensemble attendu sont en tête du script, sous un commentaire qui renvoie ici. On ne les modifie que sur la preuve que demandent les sections suivantes, jamais pour faire passer l'étape au vert.
|
||||
|
||||
**Friction assumée** : chaque mise à jour qui déplace ces arêtes (celles qui atteignent `hyper`, entre autres) fera échouer l'étape et demandera une édition à la main du lock. C'est l'objet : un effet de bord silencieux devient une décision explicite.
|
||||
|
||||
### Éprouvé par mutation
|
||||
|
||||
Le 2026-10-10, le texte du `run:` a été extrait du workflow et lancé tel quel, avec les options du runner (`bash --noprofile --norc -e -o pipefail`), sur des copies du lock :
|
||||
|
||||
| lock | sortie |
|
||||
|---|---|
|
||||
| celui de `main`, et ceux des tags v0.14.0 à v0.17.1 | exit 0, 9 vérifications |
|
||||
| dérivé par `cargo update -p hyper` | exit 1, les sept arêtes en échec |
|
||||
| un bloc `windows-sys` 0.53.0 ajouté | exit 1, ensemble des versions |
|
||||
| `colored` renommé | exit 1, bloc introuvable |
|
||||
| absent (`awk` sort en 2) | exit 1, 0 vérification |
|
||||
| `tempfile` retiré de la liste gardée, ou liste vide (script modifié) | exit 1, canari |
|
||||
|
||||
## Procédure quand le garde échoue
|
||||
|
||||
La ligne `FAIL:` dit quelle règle a cédé.
|
||||
|
||||
- **Une arête a quitté `0.61.2`** (règle 1), en général après un `cargo update` (`-p hyper`, `-p h2` et `-p rustls-webpki -p tar` l'ont fait). Garder la mise à jour voulue et remettre les arêtes, de l'une de deux façons qui mènent au même lock :
|
||||
- n'appliquer à la main que la ou les strophes du paquet visé (version, checksum, dépendances), comme la PR #344 l'a fait pour `h2` ;
|
||||
- ou garder la sortie de `cargo update` et ramener à la main les arêtes signalées à `"windows-sys 0.61.2"`, les seules lignes à toucher.
|
||||
|
||||
Puis vérifier que cargo accepte le lock tel quel, sans le réécrire :
|
||||
|
||||
```
|
||||
cargo metadata --manifest-path src-tauri/Cargo.toml --format-version 1 --locked > /dev/null
|
||||
```
|
||||
|
||||
Éprouvé le 2026-10-10 sur la copie dérivée par `cargo update -p hyper` : les sept arêtes remises, le diff contre `main` se réduit aux strophes `hyper` et `want`, `cargo metadata --locked` l'accepte sans modifier le fichier, et le garde repasse.
|
||||
- **L'ensemble des versions a changé** (règle 3). Pour une version ajoutée, chercher qui la tire et si elle est compilée pour Windows (`cargo tree --manifest-path src-tauri/Cargo.toml -i windows-sys@<version> --target x86_64-pc-windows-msvc`) ; si c'est le début du passage à 0.62, la condition de sortie est atteinte (section suivante). Une version retirée est un pas vers la convergence. Dans les deux cas, `EXPECTED` change dans la PR qui change le lock, preuve à l'appui dans la PR.
|
||||
- **Bloc introuvable** : le crate a été renommé ou est sorti du graphe. Retirer son nom de `CRATES` après avoir revérifié la liste des bornes larges (commande de la section Conséquences).
|
||||
- **Plus d'arête `windows-sys` dans un bloc gardé** (`has no windows-sys edge left`) : le crate a cessé de dépendre de `windows-sys` dans cette version. Même traitement qu'un bloc introuvable ; si c'est `tempfile`, choisir aussi un autre canari parmi les crates restants.
|
||||
- **Un second bloc d'un crate gardé dont la borne exclut `0.61.2`** (par exemple un `tempfile` plus ancien, d'avant les bornes larges, tiré par une autre dépendance) : la règle 1 échoue, et la réparation de la première puce est impossible, puisque `cargo metadata --locked` refuse une arête hors borne (code 101, éprouvé à la revue de la PR #415). Décider dans la PR qui change le lock : admettre ce bloc par nom et version dans le script, ou écarter la dépendance qui le tire ; dans les deux cas, amender cet ADR.
|
||||
- **Canari ou `awk` en échec** : le garde lui-même est cassé. Le réparer ; ne jamais changer le canari ou la liste pour le faire taire.
|
||||
|
||||
## Condition de sortie
|
||||
|
||||
L'épinglage n'est pas fait pour durer indéfiniment. Il change ou disparaît dans deux cas.
|
||||
|
||||
- **Au passage de l'écosystème à `windows-sys` 0.62.** La borne `<=0.61` de `colored` et `winapi-util`, comme le `<0.62` d'`errno`, `rustix`, `tempfile` et `rustls-platform-verifier`, les empêche de suivre ; seul `dirs-sys` (`>=0.59.0`, sans plafond) le peut. Quand un crate du graphe tirera 0.62, la règle 3 rougira et il faudra décider sur preuve : une nouvelle configuration n'est tenue qu'après un vrai build Windows, c'est-à-dire un run `release.yml`. `PIN`, `EXPECTED` et au besoin `CRATES` changent alors dans la même PR que le lock, avec un amendement de cet ADR. Le garde est mis à jour, jamais contourné.
|
||||
- **À la convergence vers un seul `windows-sys`**, la sortie souhaitée. Plus aucune arête n'a où dériver, et le coût mesuré disparaît. Le garde le signalera (règle 3 en échec, arêtes nues admises par la règle 2) ; l'étape peut alors être retirée, avec un amendement de cet ADR.
|
||||
|
||||
## Alternatives considérées
|
||||
|
||||
- **Check croisé Windows en CI** (`cargo check --target x86_64-pc-windows-msvc` depuis Linux). L'option la plus attirante, puisqu'elle prouverait que le graphe Windows compile quel que soit l'état. Testée à l'analyse de #345, elle échoue : `cargo check` a besoin du toolchain natif de la cible dès qu'un build script compile du C, et `cc-rs` s'arrête sur `failed to find tool "lib.exe"`. Il faudrait installer sur chaque PR Rust le toolchain croisé que `release.yml` n'installe qu'au tag (`cargo-xwin` compilé depuis les sources, `clang`, `lld`), sur un runner à capacité 1 : un chantier, pas une étape bon marché. À rouvrir si le besoin d'une preuve Windows revient pour d'autres raisons.
|
||||
- **Garde sur la propriété** (« au plus 3 `windows-sys` dans le graphe Windows »). Il encode le dommage mesuré plutôt qu'un numéro, survit au passage à 0.62 et couvrirait un nouveau crate à borne large. Mais il reste indirect à la lecture, tolère un échange à compte constant, et demande `cargo tree` : le lock ne dit pas quelle arête est compilée pour quelle cible. Écarté au profit de la cohérence avec le motif existant.
|
||||
- **Garde sur une liste de crates déclencheurs** (`hyper`, la couche TLS). La liste est précisément ce que #348 a trouvé faux.
|
||||
- **Commentaire seul dans `Cargo.toml`.** `Cargo.toml` n'est pas ce qu'on lit avant de lancer `cargo update`, et rien ne l'exécute.
|
||||
|
||||
## Conséquences
|
||||
|
||||
### Positives
|
||||
|
||||
- La dérive ne passe plus en silence : une mise à jour qui déplace une arête rougit `check-rust` sur la PR même, avec le nom de chaque arête déplacée, au lieu d'attendre qu'un relecteur reconnaisse sept noms de crates dans un diff que cargo n'a pas annoncé.
|
||||
- La contrainte est domiciliée : qui voit l'étape rougir trouve ici pourquoi, et quoi faire.
|
||||
- Le motif est celui que le dépôt connaît déjà (ADR 0018), pièges compris.
|
||||
|
||||
### Négatives
|
||||
|
||||
- **Friction** : chaque mise à jour qui déplace ces arêtes demande une édition à la main du lock.
|
||||
- **La liste des sept est tenue à la main.** Un crate à borne large qui entrerait dans le graphe ne serait pas gardé, et s'il pointait une version déjà présente, la règle 3 ne le verrait pas non plus. À l'inverse, retirer de `CRATES` un autre nom que le canari passe, avec une vérification de moins dans le bilan (9 → 8) : seule la relecture du diff, sous le commentaire qui renvoie ici, l'attrape. La commande suivante liste les exigences sur `windows-sys` qui ne sont pas un `^0.x` ; le 2026-10-10, elle sort exactement les sept crates de `CRATES` :
|
||||
|
||||
```
|
||||
cargo metadata --manifest-path src-tauri/Cargo.toml --format-version 1 --locked \
|
||||
| python3 -c 'import json, sys; [print(p["name"], p["version"], d["req"]) for p in json.load(sys.stdin)["packages"] for d in p["dependencies"] if d["name"] == "windows-sys"]' \
|
||||
| grep -v ' \^0\.'
|
||||
```
|
||||
|
||||
- La règle 3 compte toutes les cibles : 0.45.0, 0.48.0 et 0.52.0 ne sont pas compilées pour `x86_64-pc-windows-msvc` aujourd'hui, et un changement qui ne touche qu'elles la fera rougir quand même. Assumé : le lock ne dit pas ce qui est compilé pour quelle cible, et une fausse alerte se lève en une vérification `cargo tree`.
|
||||
- Le garde tourne sur les PR qui touchent `src-tauri/**`, `.cargo/**` ou le workflow, et le lundi sur `main`, pas en continu. Une exception : le commit de `/release` modifie `Cargo.lock` directement sur `main`, sans PR, et le garde ne le verrait qu'au lundi suivant, après le build du tag. Le skill `/release` le rejoue donc en local juste après avoir édité le lock, avant de tagger (étape 2).
|
||||
|
||||
### Neutre
|
||||
|
||||
- Le garde lit le lock, il ne prouve pas que le graphe Windows compile. Cette preuve reste le run `release.yml`.
|
||||
- Rien ne change pour l'application : #345 lit `Cargo.lock` sans le modifier, et ne déplace aucune dépendance.
|
||||
|
||||
## Liens
|
||||
|
||||
- [`.forgejo/workflows/check-rust.yml`](../../.forgejo/workflows/check-rust.yml) — étape « Verify windows-sys pinning »
|
||||
- [ADR 0018](0018-suppression-advisories-non-atteignables.md) — le garde-fou des suppressions, dont celui-ci reprend le motif
|
||||
- Issues #345 (cette décision), #348 (mesure `rustls`), #310 (première dérive) ; PR #344 (strophe `h2` appliquée à la main), PR #321 (arêtes remontées à `0.61.2`)
|
||||
87
docs/adr/0024-serveur-mcp.md
Normal file
87
docs/adr/0024-serveur-mcp.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# ADR 0024 — Serveur MCP : un mode `--mcp` du binaire, stdio seul, accès par profil, Premium
|
||||
|
||||
- Status: **Proposed** (écrit avant le code, à passer Accepted au merge de la milestone `spec-mcp-server`)
|
||||
- Date: 2026-10-12
|
||||
- Issues: milestone `spec-mcp-server` (à créer depuis `spec-plan-mcp-server.md`), revue `/review-spec` du 2026-10-12
|
||||
- S'appuie sur l'[ADR 0017](0017-feature-gating-par-tier.md) (gating par édition, `FEATURE_TIERS` côté Rust), l'[ADR 0021](0021-migrations-profils-secondaires.md) (`prepare_at`, rattrapage des profils secondaires) et l'[ADR 0022](0022-canal-evenements-donnees.md) (canal d'événements de données), qu'il amende
|
||||
|
||||
## Contexte
|
||||
|
||||
Simpl'Résultat garde toutes les données financières en local et n'envoie rien à un serveur tiers. Les agents IA (Claude Code, Claude Desktop, Hermes, Cursor) parlent un protocole commun, le Model Context Protocol (MCP), pour appeler des outils exposés par un programme local. Aujourd'hui rien ne relie l'app à ces agents : pour demander « où déborde mon budget », l'utilisateur exporte un CSV et le colle.
|
||||
|
||||
Trois faits du code structurent la décision :
|
||||
|
||||
- **La logique métier vit dans la webview**, en TypeScript, via `tauri-plugin-sql` (`src/services/*`). Côté Rust, seul `rusqlite` ouvre les bases, pour les migrations des profils secondaires et le rendement Modified Dietz.
|
||||
- **Le PIN ne protège que l'interface** : les bases SQLite sont en clair sous `app_config_dir()`.
|
||||
- **L'édition se résout en Rust** depuis `license.key` (et le cache de compte), avec révocation et fail-closed.
|
||||
|
||||
Et un fait du protocole : en MCP, ce n'est pas l'app qui transmet des données, c'est l'hôte. Chaque résultat d'outil part chez le fournisseur du modèle de l'agent. Le principe privacy-first de l'app reste vrai au sens strict, mais l'utilisateur doit l'activer en connaissance de cause.
|
||||
|
||||
## Décision
|
||||
|
||||
**Un mode `--mcp` de l'exécutable existant sert un serveur MCP sur stdio avec `rmcp` (SDK Rust officiel, spec 2026-07-28), ouvre en direct la base SQLite d'un profil que l'utilisateur a explicitement autorisé dans l'app, et expose des outils de lecture, deux outils d'écriture et deux prompts. Réservé à l'édition Premium.**
|
||||
|
||||
### 1. Un binaire autonome, pas un serveur hébergé par l'app
|
||||
|
||||
`simpl_result_lib::entry(args)` choisit `mcp::run_stdio()` si `--mcp` est présent, sinon `run()`. Aucun `tauri::Builder`, aucun plugin single-instance ni deep-link dans ce mode. Le serveur marche app fermée, et rien n'est à empaqueter : pas de sidecar `externalBin`, pas de changement dans `release.yml`.
|
||||
|
||||
Le coût accepté : le SQL des outils est réécrit en Rust. Les services TypeScript restent la référence, et des **tests de parité** exécutent le texte SQL de référence (`compareDeltaSql` pour le résumé mensuel, `getExpensesByCategory` pour la répartition) sur une fixture commune et le comparent aux outils. Chaque constante SQL Rust porte un commentaire `// ref: src/services/<fichier>.ts:<ligne>`.
|
||||
|
||||
### 2. stdio seul
|
||||
|
||||
Aucun port, aucune authentification à inventer, pas de rebinding DNS depuis une page web (CVE-2025-66414 sur les serveurs MCP HTTP locaux ; les trois CVE connues de `rmcp` touchent ses transports HTTP, absents des features retenues). Tout client stdio local fonctionne avec la même commande ; Paramètres affiche celle de Claude Code et le JSON de Claude Desktop. Un hôte distant (Hermes sur le VPS) est hors scope : il demanderait Streamable HTTP, un jeton et un tunnel.
|
||||
|
||||
Stdout appartient au protocole : rien d'autre que `rmcp` n'y écrit, les journaux vont sur stderr. Un test lance le binaire et vérifie que le premier octet reçu ouvre un objet JSON.
|
||||
|
||||
### 3. Accès par profil, explicite, avec avertissement
|
||||
|
||||
Un drapeau `agent_access` dans `profiles.json` (`#[serde(default)]`, absent = faux), posé dans l'app par un interrupteur sur la fiche du profil. Pour tout profil, avec PIN ou non ; la saisie du PIN précède l'activation quand le profil en a un. L'avertissement dit ce qui se passe : l'agent pourra lire toutes les transactions, catégories, budgets et notes du profil, en modifier la catégorisation, et ces données seront transmises au fournisseur du modèle de cet agent ; Simpl'Résultat n'envoie rien lui-même.
|
||||
|
||||
Le serveur ne voit que les profils autorisés (`list_profiles`), en sélectionne un par session (`select_profile`), et **relit le drapeau à chaque appel d'outil** : désactivé dans l'app, l'appel suivant répond `profile_not_allowed` et la session se ferme.
|
||||
|
||||
Le PIN n'est jamais demandé par le serveur ni transmis à l'hôte.
|
||||
|
||||
### 4. Premium, vérifié par le même résolveur que l'app
|
||||
|
||||
`("mcp", &[EDITION_PREMIUM])` dans `FEATURE_TIERS`. La chaîne de résolution (dev-override → compte → clé, révocation, fail-closed) est extraite en `license_commands::entitlements_in(data_dir)`, appelée par l'app et par le serveur : un seul résolveur. Le serveur démarre et répond à `initialize` en Free ou Base, mais chaque outil répond `license_required` ; l'édition est relue à chaque appel, donc une clé saisie dans l'app prend effet sans redémarrer l'hôte. Le fichier `license.check` n'est écrit que par l'app : une révocation en ligne n'est détectée qu'au prochain démarrage de l'app.
|
||||
|
||||
En mode `--mcp`, le keyring est lu sans jamais créer d'entrée, toute erreur vaut « pas de compte vérifié », et l'appel est borné par un délai : un trousseau verrouillé donne `license_required`, jamais un blocage de `initialize`.
|
||||
|
||||
Le binaire release ignore toute variable d'environnement : les dossiers de test (`SR_MCP_DATA_DIR`, `SR_MCP_CONFIG_DIR`) ne sont lus que sous la feature `dev-override`, comme `SR_DEV_EDITION` (CWE-489).
|
||||
|
||||
### 5. Lecture et écritures ciblées
|
||||
|
||||
Lecture : profils, résumé mensuel (par type de catégorie, comme les rapports : revenus et transferts en somme signée, dépenses en valeur absolue, résultat avant et après transferts), répartition hiérarchique par catégorie, transactions filtrées et paginées, non-catégorisées, budget vs réel, catégories, règles de mots-clés. Écriture : `set_transaction_category` (feuille active et saisissable seulement, `is_manually_categorized = 1`, drapeaux en 1/0) et `create_keyword_rule` (mêmes règles que l'app : 2 à 64 caractères, priorité 100, réactivation d'une règle existante vers la même catégorie, refus vers une autre ; application aux transactions existantes par `LIKE ? ESCAPE` puis frontière de mot, en dry-run tant que `apply_to_existing` est faux). Rien d'autre : ni suppression, ni import, ni budget, ni catégorie nouvelle.
|
||||
|
||||
Les libellés, notes et noms de catégorie sont des données importées, jamais des consignes, et les `instructions` du serveur le disent : un libellé piégé ne peut provoquer qu'une catégorisation, réversible dans l'app.
|
||||
|
||||
Les refus sont des résultats d'outil (`isError: true`) avec un code stable et un libellé fixe, sans chemin ni message SQLite (le détail va sur stderr, passé par `scrub`), pour que le modèle les lise et se corrige. Les erreurs JSON-RPC sont réservées aux fautes de protocole.
|
||||
|
||||
### 6. Bases en WAL, cohérence avec l'app ouverte
|
||||
|
||||
Les bases passent en `journal_mode = WAL`, posé par l'app seule (`prepare_at` et après chaque `Database.load`) : lecteurs et écrivain ne se bloquent plus, deux écrivains se sérialisent par `busy_timeout`. Le serveur refuse une base qui n'y est pas (`profile_not_prepared`) ou dont les migrations ne sont pas au dernier niveau (`profile_not_migrated`) : il ne migre ni ne prépare jamais une base qu'il n'a pas créée. `VACUUM INTO` emporte les pages encore dans le WAL, sans checkpoint (vérifié).
|
||||
|
||||
L'app détecte les écritures externes au retour du focus : une connexion rusqlite sentinelle, ouverte paresseusement en `tauri::State`, lit `PRAGMA data_version`, qui est par connexion (le pool de `tauri-plugin-sql` ne convient pas). Si la valeur a changé, `notifyDataChanged()` ; la resynchronisation après une écriture interne se fait en un seul point, dans `notifyDataChanged`. Faux positif toléré, courte fenêtre de faux négatif acceptée.
|
||||
|
||||
**Amendement de l'ADR 0022** : §2, un nouvel émetteur, le contrôle au focus ; §5, de nouveaux abonnés, les hooks des pages que le serveur peut modifier (`useTransactions`, `useDashboard`, `useBudget`, liste des non-catégorisées), avec les règles existantes (attente de 150 ms, réponses périmées ignorées, désabonnement au démontage, un test par hook).
|
||||
|
||||
### 7. Ce que le serveur ne fait pas
|
||||
|
||||
Pas de transport HTTP, pas de resources MCP, pas d'outils Bilan, pas d'écriture hors catégorisation, pas de masquage des libellés, pas de PIN par élicitation, pas de chiffrement des bases. Chaque point est une phase 2 possible, listée dans `spec-decisions-mcp-server.md`.
|
||||
|
||||
## Conséquences
|
||||
|
||||
- **Positives** : un accès agent standard, utilisable par tout client stdio local ; un consentement explicite par profil ; une seule chaîne d'édition ; le WAL profite aussi à l'app (une lecture ne bloque plus une écriture) ; les hooks de page gagnent un rafraîchissement ciblé réutilisable au-delà du MCP.
|
||||
- **Négatives** : du SQL en double, tenu par les tests de parité ; trois fichiers par base (`.db`, `-wal`, `-shm`), à dire dans le guide pour les sauvegardes à la main ; une base jamais ouverte par la version courante de l'app est refusée par le serveur ; le binaire Windows release est compilé sans console, et l'héritage des pipes de l'hôte se prouve en QA (repli : retirer `windows_subsystem` et masquer la console au démarrage GUI).
|
||||
- **Privacy** : la politique de confidentialité (la-compagnie-maximus#184) doit dire que l'accès agent, une fois activé par l'utilisateur, fait transiter les données du profil par l'hôte MCP et son fournisseur de modèle.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Serveur hébergé par l'app en marche** (sidecar stdio relié par socket, pont Rust ↔ webview vers les services TS) : zéro duplication et `dataEvents` natif, mais l'app doit tourner, un binaire de plus dans `externalBin` et `release.yml`, et un pont à concevoir.
|
||||
- **Hybride** (lecture directe app fermée, relais app ouverte) : deux chemins à tester ; réservé à une phase 2 si le binaire autonome montre ses limites.
|
||||
- **Streamable HTTP** : un port ouvert sur les données financières, un jeton à gérer, le rebinding DNS à contrer ; aucun hôte local n'en a besoin.
|
||||
- **PIN demandé par élicitation MCP** : le PIN transiterait par l'hôte, et tous les clients ne supportent pas l'élicitation.
|
||||
- **Profils sans PIN seulement** : un utilisateur qui protège son profil perdrait la fonctionnalité.
|
||||
- **Agrégats seulement par défaut** : catégoriser exige le libellé ; deux niveaux d'exposition à expliquer pour un gain incertain.
|
||||
- **Remonter `refreshKey` au focus** : trois lignes, mais la page perdrait filtres et défilement à chaque retour.
|
||||
- **`wal_checkpoint(TRUNCATE)` avant `VACUUM INTO`** : inutile (la copie lit la vue de la connexion, WAL compris) et nuisible (échoue en `SQLITE_BUSY` dès qu'un lecteur est ouvert).
|
||||
119
docs/analyse-onboarding-2026-09.md
Normal file
119
docs/analyse-onboarding-2026-09.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
# Analyse — Embarquement des nouveaux utilisateurs
|
||||
|
||||
> Date : 2026-09-24
|
||||
> Base : code de v0.15.0 (`main`), guide utilisateur, fixtures CSV, configuration Tauri.
|
||||
> Question : comment faciliter l'embarquement d'un nouvel utilisateur ? Options de départ : tour guidé intégré, vidéo, guide utilisateur bonifié, jeu de données de test.
|
||||
> Statut : document de travail, à faire évoluer.
|
||||
|
||||
## Verdict
|
||||
|
||||
Traiter d'abord le décrochage réel (états vides, liste de démarrage, dossier d'exemple, guides d'export CSV par banque), puis les données de démo et le guide publié sur le site. La vidéo et le tour guidé viennent en troisième, seulement si les testeurs le demandent.
|
||||
|
||||
Le nouvel utilisateur décroche sur trois étapes hors de l'app que rien dans l'app n'accompagne : choisir un dossier d'import, créer un sous-dossier par banque, exporter le CSV depuis le site bancaire. Un tour guidé ne peut pas l'y accompagner ; une vidéo vieillit à chaque refonte ; seuls le guide et les données d'exemple y répondent.
|
||||
|
||||
## 1. Ce qui existe déjà, et où ça coince
|
||||
|
||||
- **Le guide est enterré.** Il vit en 3e section de Paramètres → Utilisateurs (`UsersSettingsPage.tsx`), sous les cartes Compte et Licence, sans lien dans la barre latérale ni sur le site. Les routes `/docs` et `/changelog` ne sont plus que des redirections. Il existe en trois copies à maintenir : `docs/guide-utilisateur.md` (559 lignes) plus les clés `docs.*` de `fr.json` et `en.json` rendues par `DocsContent.tsx`.
|
||||
- **Les popovers « ? »** (`PageHelp.tsx`) couvrent 8 pages (dashboard, import, transactions, catégories, ajustements, budget, rapports, paramètres) avec des astuces. Bon socle contextuel, sous-exploité : aucun lien profond vers le guide.
|
||||
- **Aucun état vide guidé.** Un nouveau profil arrive sur un tableau de bord à 0 $ sans consigne (`DashboardPage.tsx` n'a pas de branche « aucune transaction »). Le « Démarrage rapide » du guide compte 7 étapes, dont 3 hors de l'app.
|
||||
- **Contraintes pour la vidéo.** Installeurs à 5,5 Mo (Windows) et 9,7 Mo (Linux, `.deb` et `.rpm`). La CSP de `tauri.conf.json` n'a ni `media-src` ni `frame-src`, et le principe privacy-first interdit un iframe YouTube. Une vidéo vit donc sur le site, ouverte dans le navigateur système via `tauri-plugin-opener`, déjà présent.
|
||||
- **Matière pour un jeu de données.** 15 fixtures CSV de test existent dans `src/__fixtures__/csv/` (formats réels Desjardins, BNC, RBC, Tangerine) mais restent minuscules. Les profils étant des bases séparées, un profil de démonstration est naturel.
|
||||
- **Gating Free.** Le multi-profil est verrouillé en édition Free (ADR 0017). Une démo doit donc vivre soit hors du compte de profils, soit comme données chargeables puis effaçables dans le profil courant.
|
||||
- **Drapeaux.** `user_preferences` (par profil, via `userPreferenceService.ts`) peut porter un « liste masquée » ou « tour vu » sans migration.
|
||||
|
||||
## 2. Options évaluées
|
||||
|
||||
Efforts en jours de développement. Aucune option ne demande de migration DB.
|
||||
|
||||
| Option | Effort (j) | Complexité | Vieillit | Valeur onboarding |
|
||||
|---|---|---|---|---|
|
||||
| A. États vides avec appel à l'action (dashboard, import, transactions, budget) | 1 à 2 | faible | non | haute |
|
||||
| B. Liste de démarrage sur le tableau de bord, calculée depuis l'état réel | 2 à 3 | faible à moyenne | non | haute |
|
||||
| C. Tour guidé en surimpression (driver.js) | 3 à 5 | moyenne | oui, à chaque refonte | moyenne |
|
||||
| D1. Dossier d'exemple : bouton qui écrit 2 ou 3 CSV réalistes dans le dossier d'import | 1 à 2 | faible | non | haute |
|
||||
| D2. Données de démo complètes (18 mois, règles, budget, snapshot Bilan) | 3 à 5 | moyenne | peu | haute |
|
||||
| E. Guide bonifié : source unique, tâches, captures, export CSV par banque, publication site | 3 à 5 | faible | oui, captures | haute |
|
||||
| F. Vidéos courtes avec voix remplacée | 1 par vidéo, puis rejouer à chaque changement visuel | faible technique, haute maintenance | vite | moyenne |
|
||||
|
||||
### A. États vides
|
||||
|
||||
Chaque page vide dit quoi faire ensuite et offre le bouton qui y mène : « Choisir un dossier d'import » sur le tableau de bord, « Créer un dossier d'exemple » sur Import, « Importer d'abord » sur Transactions et Budget. Zéro dépendance, zéro drapeau à stocker.
|
||||
|
||||
### B. Liste de démarrage
|
||||
|
||||
C'est le « tour guidé » qui ne bloque personne. Six cases cochées depuis les données, pas depuis un drapeau :
|
||||
|
||||
1. Profil créé
|
||||
2. Dossier d'import choisi
|
||||
3. Première source détectée
|
||||
4. Premier import réalisé
|
||||
5. Première règle de catégorisation créée
|
||||
6. Premier budget saisi
|
||||
|
||||
Elle ne se désynchronise jamais de l'UI et disparaît une fois complète ou masquée (préférence par profil dans `user_preferences`). La plupart des produits qui ont abandonné les tours en surimpression sont passés à ce modèle.
|
||||
|
||||
### C. Tour guidé
|
||||
|
||||
Possible, mais en second. driver.js 1.8.0 est MIT, environ 5 ko, sans dépendance React. react-joyride 3.2.0 est MIT mais plus lourd et lié à React. Shepherd.js 15 et intro.js 8 sont passés AGPL-3.0, à écarter. Un tour dépend de sélecteurs DOM, d'un drapeau par profil et de textes i18n en double. Il se casse à chaque refonte (l'import a été refait en août 2026, le gating en septembre) et ne montre que ce que l'écran affiche. Cinq ou six étapes maximum, seulement si les testeurs le réclament.
|
||||
|
||||
### D1. Dossier d'exemple
|
||||
|
||||
Enseigne la convention en la faisant. Sur la page Import vide, « Créer un dossier d'exemple » écrit `Exemple Desjardins/releve-2026-08.csv` et `Exemple Tangerine/releve-2026-08.csv` via les commandes fs existantes, dans le dossier d'import. L'utilisateur voit la détection automatique, l'aperçu signé et la déduplication sur des fichiers aux formats réels. Une fois ses vrais relevés en place, il supprime les deux sous-dossiers.
|
||||
|
||||
### D2. Données de démo complètes
|
||||
|
||||
Sert quatre usages à la fois : premier contact convaincant en 30 secondes, support d'enregistrement pour la vidéo, captures du guide, cible stable pour un tour.
|
||||
|
||||
- Générer les données en code déterministe (marchands québécois, montants crédibles, alignés sur le seed de catégories standard pour que l'auto-catégorisation tombe à 80 % et plus). Rien à embarquer dans l'installeur.
|
||||
- Pour respecter le gating Free : « Charger les données d'exemple » dans le profil courant vide, transactions rattachées à une source d'import nommée `Exemple`, et « Effacer les données d'exemple » qui supprime par source. Le budget et le snapshot Bilan de démo suivent le même marquage.
|
||||
- Point d'attention : le réalisme. Des données visiblement fausses (montants ronds, marchands génériques) enlèvent la valeur de démonstration.
|
||||
|
||||
### E. Guide bonifié
|
||||
|
||||
Trois chantiers indépendants :
|
||||
|
||||
1. **Source unique.** Deux markdown FR/EN copiés dans `public/` par le mécanisme `syncChangelogs` déjà en place dans `vite.config.ts` et rendus in-app comme le changelog. Supprime le doublon i18n des clés `docs.*`.
|
||||
2. **Contenu.** Réorganiser par tâches (« Je veux… »), ajouter des captures prises sur les données de démo, et surtout des pages « Exporter un CSV depuis AccèsD, Tangerine, RBC, BNC », le vrai point de friction que rien dans l'app ne peut résoudre.
|
||||
3. **Visibilité.** Entrée « Aide » dans la barre latérale, liens profonds depuis chaque popover « ? », publication sur la page produit du site (partageable, indexé).
|
||||
|
||||
### F. Vidéo : le workflow ajusté
|
||||
|
||||
Le workflow prévu (enregistrer l'écran en lisant un texte, puis remplacer la voix) tient, avec quatre ajustements. Le coût réel n'est pas la production mais l'obsolescence : chaque changement visuel invalide le clip.
|
||||
|
||||
| Point | Recommandation | Pourquoi |
|
||||
|---|---|---|
|
||||
| Données à l'écran | Les données de démo (D2), jamais les finances réelles | Rien de personnel dans un fichier public, résultat reproductible |
|
||||
| Remplacement de la voix | Speech-to-speech (ElevenLabs Voice Changer, ou RVC en local) plutôt qu'une synthèse pure | Conserve le timing et la prosodie : l'image reste synchronisée sans montage |
|
||||
| Synthèse pure, si retenue | Azure Neural (voix fr-CA Sylvie, Antoine, Jean), ElevenLabs multilingue ; Piper en libre mais qualité moyenne | Force à recaler chaque segment sur l'enregistrement |
|
||||
| Format | 3 ou 4 clips de 60 à 90 s, un par tâche (importer, catégoriser, budget, rapports) | Un clip court se refait en une heure ; une visite complète de 10 min ne se refait jamais |
|
||||
| Hébergement | mp4 sur le site ou le VPS, ouvert dans le navigateur ; pas d'iframe YouTube dans l'app | CSP sans `frame-src`, principe privacy-first, tracking |
|
||||
| Bilingue | UI en français avec voix anglaise, ou sous-titres | Le bilingue complet double le coût de chaque clip |
|
||||
|
||||
Alternative à faible coût : clips WebP muets de 5 à 10 secondes dans le guide, un par geste. Sans voix ni langue, refaits en 10 minutes.
|
||||
|
||||
### Écartés
|
||||
|
||||
- Outils de démo interactive SaaS (Arcade, Storylane) : tracking et capture desktop difficile.
|
||||
- Assistant en ligne dans l'app : réseau, contraire au principe privacy-first.
|
||||
|
||||
## 3. Recommandation
|
||||
|
||||
Trois phases, dans cet ordre. Les deux premières ne demandent aucune migration DB et ne vieillissent pas ; la troisième est conditionnelle.
|
||||
|
||||
| Phase | Contenu | Durée | Ce que ça règle |
|
||||
|---|---|---|---|
|
||||
| 1 | A + B + D1 + pages « exporter son CSV » par banque dans le guide | 1 semaine | Le décrochage réel : dossier, sous-dossier, CSV |
|
||||
| 2 | D2 + E (source unique, entrée Aide, publication site, captures sur la démo) | 1 semaine | Premier contact en 30 s, guide trouvable et maintenable |
|
||||
| 3, optionnelle | 3 vidéos courtes sur la démo avec voix remplacée ; tour driver.js seulement si les testeurs le demandent | 1 j par vidéo, 3 à 5 j pour le tour | Marketing et réassurance, plus qu'onboarding |
|
||||
|
||||
**Mesure.** Sans télémétrie, la mesure passe par les deux testeurs et le Feedback Hub : leur demander explicitement à quelle étape ils ont hésité, avant et après la phase 1.
|
||||
|
||||
**Prochaine étape proposée.** Un `/spec` sur la phase 1 pour trancher le détail des six cases de la liste de démarrage et le contenu exact des deux CSV d'exemple, puis une milestone Forgejo.
|
||||
|
||||
## 4. Questions ouvertes
|
||||
|
||||
- [ ] Quelles banques couvrir en priorité dans les guides d'export CSV ? (Desjardins, Tangerine, RBC, BNC ont déjà une signature d'import)
|
||||
- [ ] Le dossier d'exemple doit-il aussi couvrir un fichier de titres pour le Bilan (import de titres #245) ?
|
||||
- [ ] La liste de démarrage compte-t-elle le budget, verrouillé en Free ? Sinon, cinq cases en Free, six en Base et plus.
|
||||
- [ ] Où publier le guide sur le site : sous `/produits/simpl-resultat` ou une section `/guide` dédiée ?
|
||||
- [ ] Voix de remplacement : outil retenu et budget mensuel acceptable.
|
||||
|
|
@ -538,14 +538,16 @@ Retry-After: 42
|
|||
| `invalid_symbol` | `errors.invalidSymbol` | « Symbole invalide » | "Invalid symbol" |
|
||||
| `invalid_date` | `errors.invalidDate` | « Date invalide » | "Invalid date" |
|
||||
| `missing_param` | `errors.missingParam` | « Paramètre manquant » | "Missing parameter" |
|
||||
| `missing_token` / `invalid_token` / `expired_token` | `errors.authFailed` | « Activation requise » | "Activation required" |
|
||||
| `missing_token` / `invalid_token` / `expired_token` | `errors.authFailed` | « Échec d'authentification — activez cette machine dans Paramètres → Utilisateurs » | "Authentication failed — activate this machine in Settings → Users" |
|
||||
| `premium_required` | `errors.premiumRequired` | « Abonnement premium requis » | "Premium subscription required" |
|
||||
| `license_revoked` | `errors.licenseRevoked` | « Licence révoquée — contactez le support » | "License revoked — contact support" |
|
||||
| `license_revoked` | `errors.licenseRevoked` | « L'activation de cette machine est liée à une licence révoquée » | "This machine's activation belongs to a revoked license" |
|
||||
| `symbol_not_found` | `errors.symbolNotFound` | « Symbole inconnu — saisissez le prix manuellement » | "Symbol not found — enter price manually" |
|
||||
| `rate_limit_exceeded` | `errors.rateLimit` | « Trop de requêtes — réessayez dans {{seconds}}s » | "Too many requests — retry in {{seconds}}s" |
|
||||
| `provider_unavailable` / `service_degraded` / `internal_error` | `errors.serverUnavailable` | « Service indisponible — saisissez le prix manuellement » | "Service unavailable — enter price manually" |
|
||||
| `service_degraded` (circuit breaker Yahoo) | `errors.bestEffortDegraded` | « Source de prix temporairement indisponible — réessayez dans {{minutes}} min ou saisissez manuellement » | "Price source temporarily unavailable — retry in {{minutes}} min or enter manually" |
|
||||
|
||||
**Depuis #365**, le client distingue `license_revoked` de `premium_required` : il lisait auparavant tout 403 comme `premium_required`. Il ne retente pas la requête, et n'enregistre pas ce refus comme une révocation de la clé : le serveur juge ici la licence du jeton d'activation, que le client ne sait pas relier à la clé enregistrée (amendement #365 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md), #367).
|
||||
|
||||
**Règle générale côté UI** : sur n'importe quelle erreur, le champ de saisie manuelle reste actif. Jamais bloquer la saisie d'un snapshot.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -26,26 +26,28 @@
|
|||
```
|
||||
simpl-resultat/
|
||||
├── src/ # Frontend React/TypeScript
|
||||
│ ├── components/ # 58 composants organisés par domaine
|
||||
│ ├── components/ # 61 composants organisés par domaine
|
||||
│ │ ├── adjustments/ # 3 composants
|
||||
│ │ ├── balance/ # 8 composants Bilan (AccountForm, BalanceAccountsTable, BalanceEvolutionChart, BalanceOnboardingCard, BalanceOverviewCard, LinkTransfersModal, SnapshotEditor, SnapshotLineRow)
|
||||
│ │ ├── budget/ # 5 composants
|
||||
│ │ ├── categories/ # 5 composants
|
||||
│ │ ├── dashboard/ # 2 composants
|
||||
│ │ ├── import/ # 14 composants (wizard d'import, dont FormatDriftPanel et RepairPathNotice)
|
||||
│ │ ├── dashboard/ # 7 composants (dont DashboardEmptyState et OnboardingChecklist)
|
||||
│ │ ├── import/ # 18 composants (wizard d'import, dont FormatDriftPanel, RepairPathNotice, ImportEmptyState, ImportFolderStructure, RootFilesWarning et SourceNameDisplay)
|
||||
│ │ ├── layout/ # AppShell, Sidebar
|
||||
│ │ ├── onboarding/ # OnboardingBanner (bandeau de démarrage, #397), StepCard et OnboardingStepCard (carte d'étape, #398)
|
||||
│ │ ├── profile/ # 3 composants (PIN, formulaire, switcher)
|
||||
│ │ ├── reports/ # ~25 composants (hub, faits saillants, tendances, comparables, zoom catégorie)
|
||||
│ │ ├── settings/ # 5 composants (+ LogViewerCard, LicenseCard, AccountCard)
|
||||
│ │ ├── shared/ # 9 composants réutilisables (dont RequireFeature, UpsellGate)
|
||||
│ │ └── transactions/ # 5 composants
|
||||
│ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil)
|
||||
│ ├── hooks/ # 18+ hooks custom (useReducer, 5 hooks rapports par domaine)
|
||||
│ │ ├── settings/ # 17 composants (dont LogViewerCard, LicenseCard, LicenseKeyForm, AccountCard, DocsContent et DocsSubsections)
|
||||
│ │ ├── shared/ # 14 composants réutilisables (dont RequireFeature, UpsellGate, UpsellDialog, PurchaseButton, EmptyState)
|
||||
│ │ └── transactions/ # 6 composants (dont SplitButton, #369)
|
||||
│ ├── contexts/ # LicenseContext (licence machine) + ProfileContext (état global profil) + OnboardingContext (parcours de démarrage, #396)
|
||||
│ ├── hooks/ # 29 hooks custom (useReducer, 5 hooks rapports par domaine)
|
||||
│ ├── pages/ # 14 pages (dont 4 sous-pages rapports)
|
||||
│ ├── services/ # 14 services métier
|
||||
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts)
|
||||
│ ├── utils/ # 13 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts)
|
||||
│ ├── services/ # 27 services métier (21 détaillés dans la section « Services TypeScript »)
|
||||
│ ├── shared/ # Types, constantes, matrice d'entitlements (entitlements.ts), gate profils (profileGate.ts), gate de la Répartition (splitGate.ts), CTA d'upsell (upsellCta.ts), URL d'achat (purchaseUrl.ts), échéance de licence (licenseExpiry.ts)
|
||||
│ ├── utils/ # 16 utilitaires purs (parsing montants/dates, détection CSV, codec de format d'import, dictionnaire lexical, signatures de banques, charts, drapeaux SQL 0/1, liste et parcours de démarrage)
|
||||
│ ├── i18n/ # Config i18next + locales FR/EN
|
||||
│ ├── __fixtures__/ # csv/ : corpus de détection (15 cas figés) ; samples/ : gabarits du dossier d'exemple embarqués par Rust
|
||||
│ ├── App.tsx # Router principal
|
||||
│ └── main.tsx # Point d'entrée
|
||||
├── src-tauri/ # Backend Rust
|
||||
|
|
@ -60,7 +62,9 @@ simpl-resultat/
|
|||
│ │ ├── database/ # Schémas SQL et migrations
|
||||
│ │ │ ├── schema.sql
|
||||
│ │ │ ├── seed_categories.sql
|
||||
│ │ │ └── consolidated_schema.sql
|
||||
│ │ │ ├── consolidated_schema.sql
|
||||
│ │ │ ├── profile_db.rs # Rattrapage des profils secondaires (#390, ADR 0021)
|
||||
│ │ │ └── fixtures/ # Schémas de création des profils publiés (tests)
|
||||
│ │ ├── lib.rs # Point d'entrée, migrations, plugins
|
||||
│ │ └── main.rs
|
||||
│ ├── capabilities/ # Permissions Tauri
|
||||
|
|
@ -71,6 +75,7 @@ simpl-resultat/
|
|||
│ ├── audit.yml
|
||||
│ └── release.yml
|
||||
├── .github/workflows/ # Miroir GitHub (dormant)
|
||||
├── scripts/ # build-latest-json.sh (latest.json de l'updater, appelé par release.yml, #320) et son test vitest
|
||||
├── docs/ # Documentation technique
|
||||
└── config/ # Configuration
|
||||
```
|
||||
|
|
@ -93,7 +98,7 @@ simpl-resultat/
|
|||
| `budget_templates` | Modèles de budget réutilisables |
|
||||
| `budget_template_entries` | Catégories et montants dans les modèles |
|
||||
| `import_config_templates` | Modèles prédéfinis de config d'import |
|
||||
| `user_preferences` | Préférences applicatives (clé-valeur) — `import_folder`, `balance_show_returns`, `balance_starter_proposed`… **et l'état de repli des catégories** (clés `reports-compare-expanded`, `reports-bva-expanded`, `reports-trends-expanded`, `budget-grid-expanded`) : persisté ici **par profil** plutôt qu'en `localStorage`, pour être détruit avec le profil (pas de résidu machine-global survivant à la suppression) — voir [ADR 0016](adr/0016-persistance-etat-ui-par-profil.md) |
|
||||
| `user_preferences` | Préférences applicatives (clé-valeur) — `import_folder`, `balance_show_returns`, `balance_starter_proposed`, `onboarding_checklist_hidden` (« 1 » quand le parcours de démarrage est masqué ; la clé est supprimée par « Afficher la liste de démarrage », #351-#352, #397), `onboarding_skipped_<clé>` (« 1 » par étape passée, #396), `onboarding_guided_initialized` (« 1 » une fois la règle D9 appliquée au profil, #396), `onboarding_acknowledged_<clé>` (« 1 » par étape de visite cochée par « J'ai compris », #425), `onboarding_visit_steps_initialized` (« 1 » une fois les étapes de visite initialisées pour le profil, #425) — voir la section « Onboarding »… **et l'état de repli des catégories** (clés `reports-compare-expanded`, `reports-bva-expanded`, `reports-trends-expanded`, `budget-grid-expanded`) : persisté ici **par profil** plutôt qu'en `localStorage`, pour être détruit avec le profil (pas de résidu machine-global survivant à la suppression) — voir [ADR 0016](adr/0016-persistance-etat-ui-par-profil.md) |
|
||||
| `balance_categories` | Taxonomie des **classes d'actif** (Liquidités, Fonds/FNB, Actions, Crypto, Autres) — `kind ∈ {simple, priced}` (défaut suggéré pour les nouveaux comptes), `custom_label` pour le renommage bilingue-safe (v12). Les ex-types véhicules (TFSA/RRSP) ont migré vers `balance_accounts.vehicle_type` (Étape 1, v12/v13, [ADR 0014](adr/0014-balance-vehicule-attribut.md)) |
|
||||
| `balance_accounts` | Comptes de bilan (rattachés à une catégorie). `currency` hardcodée à `CAD` au MVP via CHECK. `archived_at` pour soft-delete. `vehicle_type` (enveloppe fiscale nullable, v12, [ADR 0014](adr/0014-balance-vehicule-attribut.md)). `kind ∈ {simple, detailed}` + `detailed_since` (pivot faisant autorité, v15, [ADR 0015](adr/0015-balance-detail-par-titre.md)) — porte désormais l'axe simple/détaillé (auparavant dérivé de `category.kind`). **Issue #179** : 4 comptes de départ seedés (`consolidated_schema.sql`) + proposés aux profils existants via `StarterAccountsModal` |
|
||||
| `balance_snapshots` | Snapshots datés (`snapshot_date` UNIQUE) — éditer = mettre à jour les lignes, pas dupliquer |
|
||||
|
|
@ -137,6 +142,17 @@ Index Bilan (9) — 7 ajoutés en v9 :
|
|||
- FK `balance_account_transfers.account_id` → `balance_accounts(id)` `ON DELETE CASCADE`
|
||||
- FK `balance_account_transfers.transaction_id` → `transactions(id)` `ON DELETE RESTRICT` — décision structurante pour la reproductibilité Modified Dietz, voir [ADR 0010](adr/0010-fk-restrict-balance-transfers.md)
|
||||
|
||||
### Accès DB et transactions
|
||||
|
||||
`src/services/db.ts` fait passer **toute** opération sur la base par un verrou FIFO unique (`runExclusive`). tauri-plugin-sql charge SQLite dans un pool sqlx multi-connexions et n'expose aucune transaction côté JS : un `BEGIN` et son `COMMIT` envoyés par deux `execute` séparés pouvaient partir sur deux connexions et laisser une transaction d'écriture ouverte (le « database is locked » de la 0.10.0). Une lecture ou une écriture isolée prend le verrou le temps d'une instruction ; `withTransaction` le tient pour tout son rappel.
|
||||
|
||||
- **Verrou strict, non réentrant (#408).** Le rappel de `withTransaction` reçoit un **handle de transaction** (typé `Database`) qui exécute directement sur la connexion. Toute autre requête attend derrière la transaction, y compris un `getDb()` appelé depuis le rappel ou depuis une fonction qu'il appelle : celui-ci attend la transaction qui l'attend, un interblocage. Règle : dans un rappel, n'utiliser **que** le handle reçu et le passer aux fonctions appelées (par exemple `exec`, obligatoire sur `findOrCreateSecurity`). Avant #408, un drapeau global laissait passer hors verrou toute requête lancée pendant un rappel, d'où qu'elle vienne.
|
||||
- **Handle révoqué.** À la fin du rappel (succès ou erreur), le handle est révoqué : un appel tardif depuis une promesse orpheline lève `TransactionHandleRevokedError` au lieu de passer hors verrou. `close()` sur le handle lève toujours `TransactionHandleCloseError`.
|
||||
- **Changement de profil dans une seule section.** `connectToProfile`, `initializeNewProfileDb` et `closeDb` sont chacun **un seul** `runExclusive` : fermeture de l'ancien pool, puis `repair_migrations` et `prepare_profile_db` (pour `connectToProfile`, appelé seulement après la fin du `close()`), puis `Database.load` et la bascule. Ils attendent donc les opérations déjà en file et une transaction en cours, et rien ne s'exécute entre la fermeture et l'ouverture. Les helpers internes (`closeUnlocked`, `loadUnlocked`) ne prennent jamais le verrou. Pendant un rattrapage long, toute la file attend : rien d'utile ne peut tourner pendant la bascule. Un `close()` de l'ancien pool qui échoue ne bloque pas la bascule : l'état est vidé et la génération incrémentée avant le `close()`, l'échec part au Journal (`logWarn`) et le profil suivant s'ouvre quand même. `closeDb()` seul se résout aussi dans ce cas, sans connexion, et tout ancien handle refuse de tourner.
|
||||
- **Génération de connexion.** Chaque ouverture et chaque fermeture incrémentent un numéro de génération. Le proxy de `getDb()` et le handle de transaction le vérifient au moment où l'opération sort de la file : une opération mise en file pour une connexion fermée entre-temps lève `DbConnectionClosedError` (« connection closed by profile switch »), au lieu de tourner sur la base du profil suivant ou sur un pool fermé. Un handle obtenu par `getDb()` avant un changement de profil ne sert donc plus après : le reprendre par `getDb()`. Son `close()` est vérifié de la même façon et lève `DbConnectionClosedError`, au lieu de fermer la connexion du profil courant. Dans le handle de transaction, la vérification est une défense, inatteignable aujourd'hui : rien ne ferme la connexion pendant qu'une transaction tient le verrou.
|
||||
- **Alerte de blocage.** Une opération qui attend le verrou depuis plus de `BLOCKED_ALERT_MS` (15 s) écrit **un** `logWarn` au Journal, qui nomme ce qui tient le verrou (`transaction`, `query`, `profile opening`, `profile initialization`, `connection close`) et depuis quand. L'alerte cite un `getDb()` dans un rappel comme cause possible, pas comme diagnostic : une transaction longue (grosse restauration, migration de catégories sur un gros profil) dépasse aussi 15 s. Un rattrapage de migrations de plus de 15 s produit une alerte « profile opening », attendue. `logService` n'écrit qu'en mémoire et en `sessionStorage`, jamais en base : l'appel est sûr pendant que le verrou est tenu.
|
||||
- **Tests.** Seul `src/services/db.test.ts` voit le verrou (un `Database` factice qui journalise le début et la fin de chaque opération). Les tests de services mockent `db.ts` (`withTransaction: fn => fn(await getDb())`) et ne le voient pas.
|
||||
|
||||
## Système de migrations
|
||||
|
||||
Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plugin_sql::Migration` :
|
||||
|
|
@ -163,16 +179,25 @@ Les migrations sont définies inline dans `src-tauri/src/lib.rs` via `tauri_plug
|
|||
|
||||
v17 est purement additive : elle n'ajoute **ni table ni index** (20 tables / 24 index inchangés). Le `CHECK` d'`amount_mode` admet `absolute_indicator` dès maintenant, pour que le troisième mode de montant puisse être livré sans nouvelle migration, alors que l'application le **refuse** encore explicitement (voir plus bas). `LIKE` plutôt que `json_extract` : la migration ne dépend d'aucune extension JSON1 dans le SQLite embarqué. `sign_convention` n'est délibérément pas backfillée — son `DEFAULT` restitue la valeur que le code codait en dur, la seule convention passée inférable.
|
||||
|
||||
Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le schéma complet avec toutes les migrations pré-appliquées (pas besoin de rejouer les migrations).
|
||||
La liste est rendue par `migrations()` (lib.rs) ; tauri-plugin-sql ne l'applique qu'à la base du **profil par défaut** (`simpl_resultat.db`, seule URL passée à `add_migrations`). Un test épingle le SHA-384 de chaque version.
|
||||
|
||||
## Services TypeScript (18)
|
||||
Les **profils secondaires** (`profile_<id>.db`) sont créés par `get_new_profile_init_sql` : le fichier `consolidated_schema.sql`, qui porte le schéma complet, puis `history_sql`, qui inscrit toutes les versions dans `_sqlx_migrations` au format sqlx. Le plugin ne les migre jamais. Depuis #390, `prepare_profile_db` les rattrape à chaque ouverture, avant `Database.load` ([ADR 0021](adr/0021-migrations-profils-secondaires.md), `src-tauri/src/database/profile_db.rs`) :
|
||||
|
||||
- une base sans historique (créée avant #390) reçoit un **plancher** déduit de son schéma (`infer_floor`) : 6, 8, 10, 16 ou 17 selon la version qui l'a créée, tous rejoués en test depuis `database/fixtures/`. Aucune version au plus égale au plancher n'est rejouée ;
|
||||
- avant la première version à appliquer, une copie `VACUUM INTO` est prise dans `migration-backups/` à côté des bases ; elle est refaite à chaque rattrapage, une seule est gardée par profil (les plus anciennes sont supprimées après un rattrapage réussi), elle est nommée au Journal même quand le rattrapage s'arrête (`catch_up_backup`), et supprimée avec le profil (#395) ;
|
||||
- chaque version s'applique dans sa transaction, clés étrangères actives comme sous sqlx. Une version qui bute sur une clé est reprise une fois sans elles, et ses lignes sans parent repassent à NULL si la clé déclare `ON DELETE SET NULL` ;
|
||||
- la même commande répare dans tous les profils les drapeaux `is_manually_categorized` stockés en texte (`'true'` → 1).
|
||||
|
||||
Le dossier des bases est celui du plugin : `app_config_dir()`, résolu par `profile_db_path` (`profile_commands.rs`). Il diffère de `app_data_dir()`, où vit `profiles.json`, sous Linux seulement (`~/.config/com.simpl.resultat` et `~/.local/share/com.simpl.resultat`).
|
||||
|
||||
## Services TypeScript (21)
|
||||
|
||||
| Service | Responsabilité |
|
||||
|---------|---------------|
|
||||
| `db.ts` | Wrapper de connexion (tauri-plugin-sql) |
|
||||
| `db.ts` | Wrapper de connexion (tauri-plugin-sql), verrou FIFO de tous les accès et `withTransaction` (voir « Accès DB et transactions »). `connectToProfile` appelle `repair_migrations` puis `prepare_profile_db` avant `Database.load`, et écrit au Journal ce que la préparation a changé (`describePreparation`, noms de fichiers seulement, #390) ; un échec est écrit au Journal et le profil s'ouvre quand même |
|
||||
| `profileService.ts` | Gestion des profils |
|
||||
| `categoryService.ts` | CRUD catégories hiérarchiques |
|
||||
| `transactionService.ts` | CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed `TransactionLinkedToBalanceError`) |
|
||||
| `transactionService.ts` | CRUD et filtrage des transactions ; détection d'erreurs FK RESTRICT pour transactions liées à un compte de bilan (typed `TransactionLinkedToBalanceError`) ; `getTransactionCount` (compte brut de la table, sans filtre — signal des états vides de Transactions et Budget, #351/#353) |
|
||||
| `importSourceService.ts` | Configuration des sources d'import — lit et écrit le format complet via le codec `importFormat.ts` ([ADR 0019](adr/0019-format-import-persiste.md)) |
|
||||
| `importedFileService.ts` | Suivi des fichiers importés |
|
||||
| `importConfigTemplateService.ts` | Modèles de configuration d'import |
|
||||
|
|
@ -183,8 +208,11 @@ Pour les **nouveaux profils**, le fichier `consolidated_schema.sql` contient le
|
|||
| `reportService.ts` | Génération de rapports : `getMonthlyTrends`, `getCategoryOverTime`, `getHighlights`, `getCompareMonthOverMonth`, `getCompareYearOverYear`, `getCategoryZoom` (CTE récursive bornée anti-cycle), `getCartesSnapshot` (snapshot dashboard Cartes, requêtes parallèles) |
|
||||
| `dataExportService.ts` | Export de données (chiffré). L'enveloppe SREF porte un `format_version` explicite (`SREF_FORMAT_VERSION = 2`) et **inclut `import_sources` + `import_config_templates`** depuis #331 ; wipe et restauration sous `withTransaction`, modèles restaurés avant les sources avec remap de `template_id` |
|
||||
| `userPreferenceService.ts` | Stockage préférences utilisateur |
|
||||
| `onboardingService.ts` | Parcours de démarrage (#351, #396) : `getOnboardingSnapshot`, une lecture pure, lit en une passe parallèle les cinq compteurs (`import_sources`, `imported_files` terminés, transactions `is_manually_categorized = 1`, `budget_entries`, transactions), le dossier d'import, la préférence `onboarding_checklist_hidden`, les étapes passées (une préférence `onboarding_skipped_<clé>` = `"1"` par étape, lues en une requête et filtrées sur les huit clés), les étapes de visite reconnues (`onboarding_acknowledged_<clé>` = `"1"`, lues en une requête et filtrées sur `VISIT_STEP_KEYS`, #425) et le drapeau `onboarding_visit_steps_initialized` ; `initializeGuidedPath` applique la règle D9 une fois par profil, dans une transaction (un profil qui a déjà des transactions est masqué, puis `onboarding_guided_initialized` est posé) ; `initializeVisitSteps(acknowledge)` initialise les étapes de visite une fois par profil, **sans transaction** (les deux reconnaissances si `acknowledge`, puis le drapeau, par écritures idempotentes) ; `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep` et `acknowledgeStep` posent et retirent les préférences, sans notifier. Aucune UI ici : `computeChecklist` (`utils/onboardingChecklist.ts`, pur) transforme le snapshot en huit lignes — voir la section « Onboarding » |
|
||||
| `dataEvents.ts` | Canal d'événements de données (#396, [ADR 0022](adr/0022-canal-evenements-donnees.md)) : `notifyDataChanged()` après chaque écriture réussie d'une table que lit un compteur du parcours de démarrage (liste dans l'ADR), `subscribeDataChanged(listener)` rend le désabonnement. Sans React ni charge utile ; un abonné qui lève est écrit au Journal et n'empêche pas les autres |
|
||||
| `logService.ts` | Capture des logs console (buffer circulaire, sessionStorage) |
|
||||
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri) |
|
||||
| `confirmService.ts` | `confirmAction(message)` : seule voie de confirmation de l'app ([#421](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/421)), par `ask` de `@tauri-apps/plugin-dialog` (icône d'avertissement, boutons `common.confirm` / `common.cancel` dans la langue de l'app). Résout `true` sur la confirmation seulement ; un refus, la fermeture de la boîte ou un échec de l'appel (consigné par `logWarn`) résolvent `false`. Toujours attendu (`await`) : sur desktop, le plugin remplace `window.confirm` par une fonction asynchrone (une `Promise`, toujours vraie) et `window.alert` par une fonction qui ne bloque pas. `src/__integration__/native-dialog-guard.test.ts` refuse tout `confirm` / `alert` natif et toute confirmation non attendue. Seul service qui traduit, par l'instance i18next partagée |
|
||||
| `licenseService.ts` | Validation et gestion de la clé de licence (appels commandes Tauri). Les appels au serveur de licences (activation, désactivation, liste des machines) renvoient un résultat typé `{ ok, value }` / `{ ok, error }` ; `parseLicenseError` lit le JSON `{code}` de `LicenseError` et `LICENSE_ERROR_I18N_MAP` le relie à `license.errors.*` (#270). Les commandes de clé (`store_license`, `read_license`) rejettent avec le même JSON depuis #347 : la carte Licence n'affiche jamais une chaîne brute. `LicenseInfo.revoked` porte l'état de révocation que Rust tient pour la clé, et `checkLicenseRevocation` lance le contrôle de révocation, au démarrage et à la saisie d'une clé (#373), qui rend `{ outcome, revoked }` (#365). `removeLicense(releaseSlot)` retire la clé enregistrée (#370) et rend le même résultat typé. `listActivatedMachines` rend `MachineList` (`{ machines, token_dropped }`, #367) : `token_dropped` vaut `not_listed` quand Rust a supprimé le jeton d'activation en chargeant la liste. `ACTIVATION_TOKEN_LOG` porte les deux lignes de Journal à code fixe d'un jeton supprimé (`not_listed_for_this_machine`, écrite par `useLicenseMachines` ; `price_refused_revoked`, écrite par `prices.fetchPrice` de `balance.service.ts` sur un refus `license_revoked`), sans jeton, identifiant de licence ni identifiant de machine |
|
||||
| `authService.ts` | OAuth2 PKCE / Compte Maximus (appels commandes Tauri auth_*) |
|
||||
| `balance.service.ts` | Domaine Bilan — service unique avec 4 sections logiques (voir détail ci-dessous) |
|
||||
|
||||
|
|
@ -199,20 +227,21 @@ Un seul service par convention projet (1 service par domaine, splitter seulement
|
|||
|
||||
Le CRUD passe par `getDb()` + `tauri-plugin-sql` direct, **jamais** via une commande Tauri — convention projet. Les commandes Rust sont réservées au filesystem, OAuth, license, profils, feedback et au seul calcul Modified Dietz (qui a besoin d'arithmétique de dates `chrono`).
|
||||
|
||||
## Hooks (17+)
|
||||
## Hooks (25+)
|
||||
|
||||
Chaque hook encapsule la logique d'état via `useReducer` :
|
||||
|
||||
| Hook | Domaine |
|
||||
|------|---------|
|
||||
| `useCategories` | Catégories avec hiérarchie |
|
||||
| `useTransactions` | Transactions et filtrage |
|
||||
| `useTransactions` | Transactions et filtrage. `useTransactions(initialFilters?)` part de filtres de départ posés sur les filtres par défaut et lus au montage seulement (`initTransactionsState`) : l'entrée guidée de l'étape « Classer une transaction à la main » passe `GUIDED_ENTRY_FILTERS` (non catégorisées, sans borne de date). Si sa première page revient vide, le reducer retire le filtre « non catégorisées » une seule fois (`guidedFallback`) et la page relit (#399) |
|
||||
| `useDataImport` | Import de données |
|
||||
| `useImportWizard` | Assistant d'import multi-étapes. Restaure le format enregistré **tel quel** via `formatFromRow` (aucune ré-inférence), déclenche la détection seule sur une source sans format (garde `!existing`), traverse l'étape `file-preview` à **chaque** import, et n'écrit la configuration qu'à `executeImport` (point d'écriture unique) — voir la section « Import CSV » et l'[ADR 0019](adr/0019-format-import-persiste.md) |
|
||||
| `useImportWizard` | Assistant d'import multi-étapes. Restaure le format enregistré **tel quel** via `formatFromRow` (aucune ré-inférence), déclenche la détection seule sur une source sans format (garde `!existing`), traverse l'étape `file-preview` à **chaque** import, et n'écrit la configuration qu'à `executeImport` (point d'écriture unique) — voir la section « Import CSV » et l'[ADR 0019](adr/0019-format-import-persiste.md). Expose `importFolderLoaded`, posé dans le `finally` de la lecture du dossier au montage (après son premier scan) et gardé par `RESET` : l'état vide de la page Import et sa carte de démarrage l'attendent (#384, #398). Expose aussi `rootFiles`, les CSV/TXT posés à la racine du dossier d'import que le scan (`ImportFolderScan`) rend à côté des sources : posé avec elles par la même action `SET_SCANNED_SOURCES`, gardé par `RESET` comme elles, nommé à l'écran par `RootFilesWarning` (au plus cinq noms, `formatRootFiles`) et jamais écrit au Journal (#427). `executeImport` enregistre la source sous `selectedSource.folder_name`, à la création comme à la mise à jour, jamais sous `config.name` : la source et ses fichiers importés sont retrouvés par le nom du sous-dossier, que `SourceNameDisplay` affiche en lecture seule (#426) |
|
||||
| `useImportHistory` | Historique des imports |
|
||||
| `useAdjustments` | Ajustements |
|
||||
| `useBudget` | Budget |
|
||||
| `useDashboard` | Tableau de bord (`/`) — convergé sur le modèle `/reports/cartes` (#279) : KPIs+deltas/top movers/adherence budget via `getCartesSnapshot` sur un mois de référence propre au Dashboard (`referenceYear`/`referenceMonth`, défaut mois précédent), barres classées + tendance par catégorie sur une période flexible propre (`period`/dates custom), filtre compte (`accountIds`) local partagé par les deux axes temporels, tuile valeur nette (Bilan) chargée une fois, indépendante du filtre compte |
|
||||
| `useOnboardingChecklist` | Parcours de démarrage (#351, #396), appelé une seule fois par `OnboardingProvider` (`src/contexts/OnboardingContext.tsx`, monté dans `AppShell` autour de `<Outlet/>`) ; les pages le lisent par `useOnboarding()`. Reducer exporté (`onboardingChecklistReducer`) : chargements numérotés (la réponse d'un chargement autre que le dernier lancé est ignorée), actions locales (`HIDE`, `SHOW`, `SKIP`, `RESUME`, `ACKNOWLEDGE`) prioritaires sur le snapshot d'un chargement parti avant elles (`ACKNOWLEDGE` garde le verrou de carte, contrairement à `SKIP`), rafraîchissement qui garde `ready` et les compteurs, échec après un premier succès qui garde le dernier état. `createOnboardingLoader` attend la règle D9 au premier chargement, une fois (promesse partagée contre le double montage de StrictMode), et n'écrit au Journal qu'au passage en échec. `createVisitStepsInitializer` lance `initializeVisitSteps(shouldAcknowledgeVisitSteps(checklist))` une fois par fournisseur (promesse partagée), après le premier chargement réussi où `budgetReady` est vrai et le drapeau absent (`needsVisitStepsInitialization`), puis relit (#425). Relecture au montage, à chaque changement de route et 150 ms après une rafale d'événements de données (`followDataChanges`, [ADR 0022](adr/0022-canal-evenements-donnees.md)). `computeChecklist` reçoit `useEntitlement("budget")` (ligne Budget verrouillée hors Base+, exclue du total), les étapes passées et reconnues et `visitStepsInitialized` ; `deriveOnboardingView` (pur) en tire l'étape suivante, le verrou de carte (`latchCardPage`) et le mode (`bannerMode`, `utils/onboardingPath.ts`, la fin attendant `budgetReady && visitStepsInitialized`). Expose `status`, `checklist`, `counters` (le Dashboard lit `transactions` pour l'état vide), `hidden`, `budgetReady`, `visitStepsInitialized`, `nextStep`, `mode`, `cardPage`, `hide()` / `show()` / `skip()` / `resume()` / `acknowledge()` / `refresh()` |
|
||||
| `useReportsPeriod` | Période de reporting synchronisée via query string (bookmarkable) + filtre compte (`accountIds`, `import_sources.id`) via le paramètre `sources`, même mécanique bookmarkable ; défaut `[]` = aucun filtre (fondation #272). Branché sur les 7 services de rapports (#273) et exposé via `<FilterPanel>` (#274) ; adopté sur Tendances (#275) puis Comparaison et Budget (#276). Le Dashboard (`/`) n'utilise pas ce hook — il porte son propre `accountIds` local (`useDashboard`), câblé au même `<FilterPanel>` (#279) |
|
||||
| `useHighlights` | Panneau de faits saillants du hub rapports |
|
||||
| `useTrends` | Rapport Tendances (sous-vue flux global / par catégorie) |
|
||||
|
|
@ -227,6 +256,7 @@ Chaque hook encapsule la logique d'état via `useReducer` :
|
|||
| `useUpdater` | Mise à jour de l'application — gatée par l'entitlement `auto-update` (Base+) via la commande `check_entitlement` |
|
||||
| `useEntitlement` | Gating par édition : lecture **synchrone** `{ allowed, ready }` d'une `FeatureKey` depuis `LicenseContext` — `ready` évite le flash de verrouillage au boot (voir section « Gating par édition ») |
|
||||
| `useIsPremium` | Raccourci d'affichage `edition === "premium"` au-dessus de `LicenseContext` (remplace l'ancien `useLicense` supprimé, qui invoquait `get_edition` à chaque appel) |
|
||||
| `useLicenseMachines` | Carte Licence — machines activées et activation de la machine courante (#270). Reducer exporté (`licenseMachinesReducer`) : l'erreur d'activation est distincte de l'erreur de liste, et aucun chargement de la liste ne l'efface. Le serveur limite à 5 requêtes/min/IP partagées : sur `machine_limit_reached` la liste s'ouvre et se charge (verify) ; une désactivation retire la machine localement sans relancer verify ; la liste ne se charge que depuis des gestionnaires explicites, jamais depuis un effet. Un `license_revoked` reçu de l'un des trois appels a déjà été enregistré par Rust (#365) : le hook recharge alors le `LicenseContext` (`refresh`), qui repasse en édition Gratuite ; il en relit `revoked`, et `selectCanActivate` bloque « Activer ». `refresh()` du `LicenseContext` suit chaque activation ou désactivation réussie, pour resynchroniser le contexte : depuis #347 ni l'une ni l'autre ne change l'édition. Il porte aussi le retrait de la clé (#370) : `performRemoveLicense` (dépendances injectées, dont `confirm` et `t` passés par la carte) choisit la confirmation selon l'état (`removeLicenseConfirmKey` : valide, activée, révoquée) et demande la libération de la place d'une machine activée dont la clé n'est pas révoquée (`releasesSlot`) ; sur `network`, `server`, `rate_limit` ou `invalid_response`, Rust n'a rien supprimé et une seconde confirmation, qui nomme la raison, propose de retirer la clé quand même (`RELEASE_RETRY_CODES`). Une ligne au Journal par retrait, par libération ou retrait échoués (`REMOVE_LOG`), puis `refresh()`. `removing` verrouille la saisie d'une clé, « Activer » et « Désactiver » le temps du retrait. Après chaque chargement réussi de la liste, `loadMachines` relit l'état d'activation (lecture locale, aucune requête) : Rust a pu supprimer un jeton qu'aucune ligne ne représente, et « Activer » revient sans recharger la page ; une ligne au Journal à code fixe le dit quand `token_dropped` vaut `not_listed` (#367) |
|
||||
| `useAuth` | Authentification Compte Maximus (OAuth2 PKCE, subscription status) |
|
||||
|
||||
### Hook transverse — `useCollapsibleGroups`
|
||||
|
|
@ -285,33 +315,115 @@ L'étape `file-preview`, déclarée depuis l'origine dans `ImportWizardStep` san
|
|||
|
||||
15 fichiers CSV **synthétiques** (aucune donnée réelle, aucun relevé réel n'entre dans ce dépôt — les signatures de banques sont écrites depuis des dispositions documentées). Ils couvrent des formes qu'un vrai relevé ne réunit jamais toutes : montant signé, débit/crédit, débit/crédit inversés, colonne inutilisée à `0,00`, préambule, en-tête contenant un nombre, en-tête à libellé numérique, sans en-tête, tout-positif, Desjardins quoté, montant absolu + indicateur, plus les quatre dispositions de banque. Posés en #326 **avant** la refonte, avec le comportement fautif figé et marqué `KNOWN DEFECT` + l'issue devant le corriger ; chaque maillon suivant a mis à jour l'attente et retiré le marqueur, sans jamais supprimer un test.
|
||||
|
||||
### Gabarits du dossier d'exemple — `src/__fixtures__/samples/`
|
||||
|
||||
Deux relevés **inventés** (`exemple-desjardins.csv` : compte chèque Desjardins ; `exemple-tangerine.csv` : carte de crédit Tangerine ; 41 lignes chacun), avec les jetons `{{M1}}` / `{{M2}}` que la commande Rust `create_sample_import_folders` remplace par les deux mois complets précédant le clic (`MM/YYYY`). Les fichiers sont embarqués dans le binaire par `include_str!` : le dépôt est la seule source, il n'y a rien à copier au build. Le module est **séparé** de `csv/index.ts` à dessein — ce corpus est figé à quinze cas par `csvAutoDetect.test.ts` et six boucles y passent chaque fixture ; y ajouter les gabarits élargirait le contrat figé en silence. `src/__integration__/onboarding-samples.test.ts` lit les **mêmes** fichiers et fige ce que l'assistant en fera : chaque fichier reconnu par la signature de la banque qu'il imite, toutes les lignes lues, aucune ligne en erreur, totaux signés attendus, et la part catégorisée par les 180 mots-clés du seed — en laissant exprès des lignes à classer à la main (dont « GARDERIE LES PETITS PAS »), pour que la cinquième case de la liste de démarrage ait quelque chose à cocher (#354).
|
||||
|
||||
## Onboarding — états vides et parcours de démarrage
|
||||
|
||||
Phase 1 de l'accueil des nouveaux profils (#351-#357 ; analyse d'origine dans `docs/analyse-onboarding-2026-09.md`) : les états vides, la liste de démarrage et le dossier d'exemple. Phase 2 (#396-#401) : le parcours guidé, un bandeau en haut de chaque page et une carte d'étape sur la page de l'étape en cours (voir « Parcours guidé », plus bas). Aucune migration ni table : tout se lit dans les données existantes, et le parcours n'écrit que des préférences par profil.
|
||||
|
||||
**Les quatre états vides** rendent le composant partagé `EmptyState` (`src/components/shared/` : icône, titre, description, action primaire en `<Link>` ou `<button>`, action secondaire optionnelle, enfants libres). Leur signal est le **compte brut** de transactions du profil, jamais le résultat d'un filtre — un profil filtré jusqu'à zéro ligne garde sa page normale :
|
||||
|
||||
| Page | Signal | Composant | Action proposée |
|
||||
|---|---|---|---|
|
||||
| Tableau de bord `/` | `counters.transactions === 0`, compteur lu par `useOnboardingChecklist` | `DashboardEmptyState` (carte d'accueil + liste détaillée) | « Choisir un dossier d'import » → `/import` |
|
||||
| Import `/import` | aucun dossier d'import, ou dossier sans aucun sous-dossier, une fois le dossier lu (`importFolderLoaded`, #384) | `ImportEmptyState` (deux variantes, chacune avec l'encadré « Structure attendue » d'`ImportFolderStructure` : dossier parent, un sous-dossier par compte, fichiers à la racine ignorés, #422) | « Choisir un dossier » ; ou « Créer un dossier d'exemple » + « Actualiser » ; lien vers le guide `/settings/users#import-export` dans les deux cas |
|
||||
| Transactions `/transactions` | `showsEmptyState(state)` = `totalInDb === 0 && !isLoading`, `totalInDb` via `getTransactionCount` | `EmptyState` dans la page | « Importer des transactions » → `/import` |
|
||||
| Budget `/budget` | `showsEmptyState(state)` = `totalInDb === 0`, même compteur | `EmptyState` dans la page | « Importer des transactions » → `/import` |
|
||||
|
||||
**La liste de démarrage** (`OnboardingChecklist`, dans la carte d'accueil du tableau de bord vide, et dépliée en place par « Voir la liste » du bandeau de démarrage `OnboardingBanner`, que `AppShell` pose en haut de chaque page, #397) a huit lignes dans un ordre fixe (#425). Les six étapes de données se cochent par une **donnée**, jamais par un clic ; les deux étapes de visite par « J'ai compris » (et, pour Rapports, l'ouverture de la carte Tendances mise en évidence). La source de vérité est `computeChecklist(counters, { importFolderSet, budgetAllowed, skipped, acknowledged, visitStepsInitialized })` (`utils/onboardingChecklist.ts`, pur) :
|
||||
|
||||
| Case | Source de vérité |
|
||||
|---|---|
|
||||
| Créer un profil | toujours cochée — la liste n'existe que dans un profil ouvert |
|
||||
| Choisir un dossier d'import | préférence `import_folder` non vide |
|
||||
| Configurer une source d'import | `COUNT(*) FROM import_sources` ≥ 1 |
|
||||
| Importer un premier fichier | `COUNT(*) FROM imported_files f WHERE f.status = 'completed' AND EXISTS (SELECT 1 FROM transactions t WHERE t.file_id = f.id)` ≥ 1 — un fichier qui n'a amené aucune transaction (toutes ses lignes en erreur, ou sa seule ligne d'en-tête) ne compte pas : `executeImport` écrit la ligne `completed` avant d'insérer |
|
||||
| Parcourir les catégories et les mots-clés | étape de visite (#425) : préférence `onboarding_acknowledged_categories` = `"1"`, posée par « J'ai compris » sur sa carte ; verrouillée tant que `visitStepsInitialized` est faux, alors exclue du total |
|
||||
| Classer une transaction à la main | `COUNT(*) FROM transactions WHERE is_manually_categorized = 1` ≥ 1 — ni « règle créée » ni « transactions catégorisées » : le seed IPC pose 180 mots-clés dans tout profil neuf et le wizard catégorise pendant l'import, ces deux cases seraient donc cochées d'avance |
|
||||
| Saisir un premier budget | `COUNT(*) FROM budget_entries` ≥ 1 ; verrouillée hors Base+ (`useEntitlement("budget")`), alors exclue du total (7 au lieu de 8) et renvoyée vers `/settings/users` |
|
||||
| Parcourir les rapports | étape de visite (#425) : préférence `onboarding_acknowledged_reports` = `"1"` ; même verrou que la ligne Catégories avant l'initialisation |
|
||||
|
||||
`computeChecklist` rend `{ items, done, total, complete }`. Chaque ligne porte `done` (la donnée), `skipped` (l'utilisateur l'a passée) et `locked` ; au niveau de la liste, `done` compte les lignes déverrouillées faites ou passées, une fois chacune, et `complete` vaut vrai quand toutes le sont. Une ligne verrouillée reste hors du total, quel que soit son état. Les deux lignes de visite sont verrouillées tant que le profil n'a pas initialisé ses étapes de visite : un profil au parcours fini ne passe pas un instant par « Étape suivante : Parcourir les catégories » ; la liste les montre alors « en attente », jamais « verrouillées » (le badge de l'édition Base ne les concerne pas), et son compteur attend `budgetReady && visitStepsInitialized`. Dans la liste, une ligne faite n'a plus de lien, une ligne passée montre le badge « Passée » et « Reprendre », une ligne verrouillée mène à la carte Licence ; chaque état sauf « à faire » est lu par un texte masqué visuellement (`onboarding.state.*`), les icônes étant cachées aux technologies d'assistance.
|
||||
|
||||
**Le dossier d'exemple** : sur un dossier d'import sans sous-dossier, `ImportEmptyState` propose « Créer un dossier d'exemple ». `useImportWizard.createSampleFolders` invoque `create_sample_import_folders` avec le dossier et la date du jour, rescanne, puis affiche `import.empty.sampleCreated` avec les noms rendus par la commande ; ce bandeau de réussite tombe au changement de dossier. La commande refuse d'écraser (voir son contrat dans « Commandes Tauri ») ; les gabarits et leur test d'intégration sont décrits dans « Gabarits du dossier d'exemple ».
|
||||
|
||||
### Parcours guidé
|
||||
|
||||
**Le fournisseur.** `OnboardingProvider` (`src/contexts/OnboardingContext.tsx`), monté dans `AppShell` autour du bandeau et de `<Outlet/>`, appelle une seule fois `useOnboardingChecklist` : l'état garde son `useReducer` de domaine ([ADR 0002](adr/0002-useReducer-vs-redux.md)) et toute l'application le partage. Les pages et les composants le lisent par `useOnboarding()`, qui lève hors du fournisseur : `status`, `checklist`, `counters`, `hidden`, `budgetReady`, `visitStepsInitialized`, `nextStep`, `mode`, `cardPage`, et les actions `hide()`, `show()`, `skip(clé)`, `resume(clé)`, `acknowledge(clé)`, `refresh()`. Le fournisseur survit à la navigation ; un changement de profil remonte `AppShell` (`BrowserRouter key={refreshKey}`), donc un fournisseur neuf, avec son propre chargeur. Il relit son état au montage, à chaque changement de route et après les écritures annoncées sur le canal de données. Ses chargements sont numérotés et seule la réponse du dernier lancé s'applique ; une action locale (`HIDE`, `SHOW`, `SKIP`, `RESUME`, `ACKNOWLEDGE`) prime sur le snapshot d'un chargement parti avant elle ; une relecture qui échoue après un premier succès garde l'état affiché.
|
||||
|
||||
**Le canal de données.** `src/services/dataEvents.ts` ([ADR 0022](adr/0022-canal-evenements-donnees.md)) : chaque écriture qui peut changer un compteur du parcours appelle `notifyDataChanged()` une fois réussie (la liste des écritures et la règle d'ajout sont dans l'ADR), et le fournisseur, seul abonné, relit son état 150 ms après la dernière écriture d'une rafale (`followDataChanges`). C'est ce qui coche une étape pendant que sa page est ouverte.
|
||||
|
||||
**Les préférences** (`user_preferences`, par profil, donc détruites avec lui et sans effet sur les autres profils, [ADR 0016](adr/0016-persistance-etat-ui-par-profil.md)). Le fournisseur en est le seul écrivain, et aucune de ces écritures ne passe par le canal : il met son état à jour lui-même, une fois l'écriture faite.
|
||||
|
||||
| Clé | Valeur | Écrite par |
|
||||
|---|---|---|
|
||||
| `onboarding_checklist_hidden` | `"1"` quand le parcours est masqué ; un seul état pour le bandeau et la liste. Supprimée au réaffichage | `hideChecklist` (« Masquer », et « Fermer » à la fin du parcours) et la règle D9 ; `showChecklist` la supprime |
|
||||
| `onboarding_skipped_<clé>` | `"1"` par étape passée, `<clé>` étant l'une des huit clés de `CHECKLIST_ITEM_KEYS`. Lues en une requête, puis filtrées sur ces clés et sur la valeur `"1"` | `skipStep` (« Passer cette étape ») ; `resumeStep` (« Reprendre ») la supprime |
|
||||
| `onboarding_guided_initialized` | `"1"` une fois la règle D9 appliquée au profil | `initializeGuidedPath` |
|
||||
| `onboarding_acknowledged_<clé>` | `"1"` par étape de visite faite (#425), `<clé>` étant l'une des deux clés de `VISIT_STEP_KEYS` (`categories`, `reports`). Lues en une requête, puis filtrées sur ces clés et sur la valeur `"1"` | `acknowledgeStep` (« J'ai compris ») et `initializeVisitSteps` |
|
||||
| `onboarding_visit_steps_initialized` | `"1"` une fois les étapes de visite initialisées pour le profil (#425) | `initializeVisitSteps` |
|
||||
|
||||
**La règle D9.** Au premier chargement, `createOnboardingLoader` attend `initializeGuidedPath`, une fois par fournisseur (promesse partagée contre le double montage de StrictMode). Le drapeau `onboarding_guided_initialized` est d'abord lu hors transaction : une fois posé, chaque ouverture suivante coûte cette lecture et n'ouvre aucune transaction. S'il manque, une transaction le relit, puis un profil qui a déjà des transactions reçoit `onboarding_checklist_hidden = "1"`, et le drapeau est posé. Après le `COMMIT`, et seulement si le parcours vient d'être masqué, une ligne est écrite au Journal. Un échec annule les deux écritures et le chargement continue sur l'état stocké ; le drapeau absent fait réessayer à l'ouverture suivante. Un profil vide ou neuf garde donc le parcours, et un profil déjà actif le trouve masqué, réaffichable depuis Paramètres → Utilisateurs.
|
||||
|
||||
**Le cochage d'office des étapes de visite** (#425). Une seule fois par profil, après le premier chargement réussi où le droit au Budget est connu et le drapeau `onboarding_visit_steps_initialized` absent, le fournisseur appelle `initializeVisitSteps(shouldAcknowledgeVisitSteps(checklist))` (promesse partagée contre le double montage de StrictMode), puis relit. `shouldAcknowledgeVisitSteps` (pur) est vrai quand chaque ligne déverrouillée hors des étapes de visite est faite ou passée : un profil qui avait terminé le parcours les trouve faites et garde « Démarrage terminé », un profil au parcours incomplet les garde à faire. L'initialisation n'ouvre **aucune transaction** : sous le verrou strict de `db.ts` (#408), un appel de service lancé depuis le rappel d'une transaction attendrait derrière ce même verrou. Les écritures sont idempotentes, les reconnaissances d'abord, le drapeau en dernier : un arrêt entre deux refait le même calcul à l'ouverture suivante. Elle ne notifie pas le canal ([ADR 0022](adr/0022-canal-evenements-donnees.md), §5).
|
||||
|
||||
**Les modèles purs** (`src/utils/onboardingPath.ts`, sans React, routeur ni base) :
|
||||
|
||||
- `nextStep` : la première ligne déverrouillée ni faite ni passée ;
|
||||
- `CARD_PAGES` (`/import`, `/categories`, `/transactions`, `/budget`, `/reports`, ces deux pages pour les étapes de visite, #432) et `stepsOfPage` : les pages qui portent une carte d'étape, et les étapes que chacune guide ;
|
||||
- `latchCardPage` : le verrou de carte, décrit ci-dessous ;
|
||||
- `bannerMode` : `none` avant le premier chargement réussi, quand le parcours est masqué et sur le tableau de bord d'un profil sans transaction (sa carte d'accueil porte déjà la liste) ; `card` sur la page verrouillée ; `complete` quand toutes les lignes déverrouillées sont faites ou passées, une fois l'entitlement Budget connu (`budgetReady`) ; `banner` partout ailleurs ;
|
||||
- `stepNavState` et `readOnboardingNavState` : l'état de navigation de tout lien vers une étape (`{ onboarding: { focus: true } }`, plus `guided: true` pour « Classer une transaction à la main »), et la seule lecture admise de `location.state` (CWE-476) ;
|
||||
- `showsStepCard`, `cardStep` (l'étape montrée et « Étape n sur N ») et `cardNext` (après « Fait. » : le lien vers l'étape suivante, la fin, ou rien tant que la relecture n'est pas arrivée) ;
|
||||
- les modèles de carte des pages, qui rendent la phrase, l'état « fait » et la cible : `importCardModel` (suit l'assistant ; « Fait. » seulement pour un import qui a ajouté des transactions, sur le rapport comme au repos, où le compteur ne retient que les fichiers qui en ont amené), `transactionsCardModel` (phrase selon le filtre « Non catégorisées ») `budgetCardModel` (« Tout déplier » ou la cellule du mois, selon la cible que la grille remonte, calculée par `budgetOnboardingTarget`, `src/components/budget/`), `categoriesCardModel` (les mots-clés, cible « Tous les mots-clés », jamais « Réinitialiser ») et `reportsCardModel(checklist, advancedLocked)` (le hub, cible la carte Tendances ; variante qui mentionne le cadenas quand les rapports avancés sont verrouillés, licence connue) ;
|
||||
- `onboardingTargetAttributes` : les attributs du contrôle mis en évidence.
|
||||
|
||||
`deriveOnboardingView` (`useOnboardingChecklist.ts`, pur) les compose à chaque rendu du fournisseur : étape suivante, verrou de carte et mode.
|
||||
|
||||
**Le bandeau.** `OnboardingBanner` (`src/components/onboarding/`, piloté par props) est rendu par `AppShell` au-dessus de chaque page, dans le flux de la page, avec `key={pathname}` : chaque page commence liste repliée. En mode `banner` : « Démarrage n/N », qui attend `budgetReady && visitStepsInitialized`, « Étape suivante » avec le lien de l'étape, « Voir la liste » / « Fermer la liste » (`aria-expanded`, `aria-controls`) et « Masquer ». En mode `complete` : le message de fin, « Ouvrir le guide » (`/settings/users#gettingStarted`) et « Fermer », qui masque le parcours. Dans les modes `none` et `card`, il ne rend rien.
|
||||
|
||||
**Le verrou de carte.** `latchCardPage(previous, { pathname, nextStep, hidden })` rend la page qui porte la carte, ou null. Il est posé quand la route courante est celle de l'étape suivante et figure dans `CARD_PAGES`. Il tient quand l'étape se coche : la carte reste pour annoncer « Fait. » et proposer la suite, même quand l'étape suivante vit sur une autre page. Il est relâché au changement de route, quand le parcours est masqué, et par « Passer » (`SKIP`) ; le rendu suivant le repose si la nouvelle étape suivante est sur la même page. La fonction est idempotente : le fournisseur la dérive pendant le rendu et range le résultat dans son reducer (`SET_CARD_PAGE`), ce qui évite l'image de bandeau qu'un effet laisserait passer sur une page à carte.
|
||||
|
||||
**Les cartes d'étape.** `StepCard` (props seules) et `OnboardingStepCard` (connectée, seule pièce de la carte qui lit `useOnboarding()`). Chaque page qui porte une carte rend `OnboardingStepCard` en premier enfant, avant son en-tête, et ne lui passe que son modèle. La carte a un titre `h2` « Étape n sur N · <étape> », qui prend le focus après une arrivée par un lien d'étape (une fois par arrivée, garde sur `location.key`, puis l'état de navigation est consommé) ; la phrase du modèle, où les libellés des boutons viennent de leurs propres clés ; une zone `role="status"` toujours rendue, qui reçoit « Fait. » ; « J'ai compris » avant « Passer cette étape » pour une étape de visite (`onAcknowledge`, passé par `OnboardingStepCard` quand `VISIT_STEP_KEYS` contient l'étape de la carte ; `ACKNOWLEDGE` garde le verrou de carte, qui annonce « Fait. » puis l'étape suivante), « Passer cette étape » tant que l'étape n'est pas faite ; le rang « Étape n sur N » et la fin attendent `budgetReady && visitStepsInitialized` ; puis le lien vers l'étape suivante ou le message de fin. Sur la page Transactions, l'entrée guidée (`guided`) passe `GUIDED_ENTRY_FILTERS` à `useTransactions` : non catégorisées, sans borne de date, avec un repli unique sur toutes les transactions quand aucune ne reste à classer.
|
||||
|
||||
**La mise en évidence.** Elle passe par props, jamais par une requête DOM. La page tire la cible de son modèle et ne la transmet que pendant que la carte est affichée (`showsStepCard`) : `onboardingTarget` sur l'action de `EmptyState` (par `ImportEmptyState`), sur la première carte de `SourceList`, sur le bouton suivant de `WizardNavigation`, sur la catégorie de la première ligne de `TransactionTable`, sur « Tout déplier » ou la cellule du mois courant de `BudgetTable`, sur « Tous les mots-clés » de `CategoriesPage` et sur la carte Tendances du hub (`HubReportNavCard`), une seule cible à la fois. Sur le hub, la carte Tendances reçoit aussi `onNavigate`, appelé au clic avant la navigation, tant que la carte d'étape s'affiche et que l'étape n'est pas faite : il reconnaît l'étape des rapports (`acknowledge("reports")`, D17), car suivre l'anneau quitte la page et relâche le verrou de carte. Le contrôle rend `data-onboarding-target="<clé fixe>"` et `data-onboarding-highlight="true"` (`onboardingTargetAttributes`). `styles.css` dessine sur ce second attribut un contour `3px solid var(--accent)` décalé de 2 px et une pulsation `box-shadow` de 1,2 s jouée trois fois, retirée sous `prefers-reduced-motion: reduce`. `--accent` atteint 3:1 contre le fond de la page et contre les cartes, dans les deux thèmes (`styles.contrast.test.ts`). Une cible absente de l'écran laisse la carte sans anneau.
|
||||
|
||||
**Paramètres → Utilisateurs** lit `hidden` dans `useOnboarding()` et montre « Afficher la liste de démarrage » tant que le parcours est masqué, y compris quand le bandeau de cette même page vient de le masquer. Le clic passe par `show()` du fournisseur, et une confirmation remplace le bouton (#397).
|
||||
|
||||
## Gating par édition (licence)
|
||||
|
||||
Depuis le chantier #297-#301, l'accès aux modules est gaté par l'édition de licence (`free` / `base` / `premium`) **côté interface uniquement** — un soft-paywall assumé (l'app est GPL ; le seul gate appliqué côté serveur reste la récupération de cours Premium via maximus-api). La matrice complète tier → modules, le rationale et les alternatives rejetées sont dans l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
|
||||
|
||||
| Brique | Fichier | Rôle |
|
||||
|---|---|---|
|
||||
| Matrice d'entitlements | `src/shared/entitlements.ts` | Source de vérité UI : `FeatureKey` (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance`, kebab-case — namespace partagé avec le `features[]` du JWT et avec Rust) → éditions. `isEntitled(feature, edition, features)` est **fail-closed en Free** : le court-circuit `free` passe AVANT l'override signé `features[]`, pour qu'une clé copiée déclassée par le machine-binding ne récupère jamais ses features signées (CWE-863). `requiredTierFor` dérive le tier minimal pour le libellé d'upsell |
|
||||
| `LicenseContext` | `src/contexts/LicenseContext.tsx` | Provider **machine-level**, monté au-dessus de `ProfileProvider` dans `main.tsx` (survit au remount `BrowserRouter` d'un changement de profil). Charge édition + licence une fois au boot ; sur erreur de chargement, retry à backoff exponentiel plafonné (1 s → 30 s, CWE-703) en préservant la dernière édition connue. Les erreurs de validation de clé (`submitKey`) sont orthogonales : `validationError` sans toucher `status` |
|
||||
| Matrice d'entitlements | `src/shared/entitlements.ts` | Source de vérité UI : `FeatureKey` (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance`, kebab-case — namespace partagé avec le `features[]` du JWT et avec Rust) → éditions. `isEntitled(feature, edition, features)` est **fail-closed en Free** : le court-circuit `free` passe AVANT l'override signé `features[]`, pour qu'une clé résolue en `free` (invalide, expirée ou révoquée) ne récupère jamais ses features signées (CWE-863). `requiredTierFor` dérive le tier minimal pour le libellé d'upsell |
|
||||
| `LicenseContext` | `src/contexts/LicenseContext.tsx` | Provider **machine-level**, monté au-dessus de `ProfileProvider` dans `main.tsx` (survit au remount `BrowserRouter` d'un changement de profil). Charge édition + licence une fois au boot ; sur erreur de chargement, retry à backoff exponentiel plafonné (1 s → 30 s, CWE-703) en préservant la dernière édition connue. Les erreurs de validation de clé (`submitKey`) sont orthogonales : `validationError` sans toucher `status`. Porte aussi `revoked` (#365, qui remplace le drapeau de session de #347) : l'état vient de Rust (`info.revoked`), persiste à côté de la clé, et l'édition résolue est alors `free` ; le contexte ne décide rien. Il lance le contrôle de révocation au démarrage et à la saisie d'une clé (#373), par un seul lanceur (`revocationCheckLauncher`), qui rend la main aussitôt : `createRevocationCheckStarter` est consommé par le premier état `ready` et ne lance le contrôle que si une clé est enregistrée à ce moment ; `performSubmitKey` le lance après `VALIDATE_DONE` pour la clé qu'il vient d'enregistrer, sans l'attendre, et jamais après un refus ; `performRevocationCheck` écrit l'issue dans le Journal (`REVOCATION_LOG`) et ne recharge le contexte que si l'état a changé. Une clé saisie avant le premier état `ready` reçoit les deux lancements : Rust ne laisse passer qu'un contrôle à la fois, et le second lit la tentative du premier. `performSubmitKey` relit aussi l'édition depuis Rust après l'enregistrement d'une clé, pour qu'une clé révoquée ressaisie reste en Gratuite. `licenseIdentity(info)` (courriel, édition, dates) sert aussi de clé React à la carte Licence, qui se remonte pour une autre clé et relit ainsi son activation |
|
||||
| `useEntitlement` | `src/hooks/useEntitlement.ts` | Lecture synchrone `{ allowed, ready }` — `allowed` fail-closed pendant le boot (édition par défaut `free`), `ready` permet de ne jamais afficher cadenas/upsell tant que la licence n'est pas résolue |
|
||||
| `RequireFeature` | `src/components/shared/RequireFeature.tsx` | Garde de route : layout-route pathless (`<Outlet/>`) ou wrapper explicite. Loader neutre si `!ready`, `UpsellGate` si refusé |
|
||||
| `UpsellGate` | `src/components/shared/UpsellGate.tsx` | Écran verrouillé : CTA « Obtenir \<tier\> » **désactivé** avec mention « bientôt » (câblage boutique par #270) + « J'ai déjà une clé » → `/settings/users` (prop `onNavigate?` pour les hôtes modaux) |
|
||||
| `UpsellGate` | `src/components/shared/UpsellGate.tsx` | Écran verrouillé, CTA décidé par `upsellCtaFor` (#346) : « Obtenir Base » actif (`PurchaseButton`, ouvre la page produit) ; pour Premium, bouton désactivé avec la note « Premium n'est pas encore offert en ligne ». Plus « J'ai déjà une clé » → `/settings/users` (`HaveKeyButton`, seulement sous un routeur). La prop `onNavigate?` ferme les hôtes modaux après « J'ai déjà une clé » ; le lien d'achat ne l'appelle pas. Quand la clé enregistrée est révoquée, une ligne (`upsell.revoked`) le dit au-dessus des CTA : le verrou apparaîtrait sinon sans raison (#365). La carte Mises à jour reprend la même ligne dans sa branche Gratuite |
|
||||
| CTA partagés | `src/components/shared/PurchaseButton.tsx`, `DownloadLatestButton.tsx`, `HaveKeyButton.tsx` | « Obtenir Base » et « Télécharger la dernière version » ouvrent `purchaseUrl` dans le navigateur, sans routeur ; ils servent aussi la carte Mises à jour et `ErrorPage` en édition Gratuite, sans aucune vérification de mise à jour. « J'ai déjà une clé » ne rend rien hors `BrowserRouter` (#346) |
|
||||
| Règle du CTA | `src/shared/upsellCta.ts` | `upsellCtaFor(tier)` → `{ purchasable, noteKey }` : Base achetable, Premium désactivé avec `upsell.premiumNotForSale` ; testée sur toute la matrice (#346) |
|
||||
| Cadenas navigation | `Sidebar.tsx` (`NavLock`) + `ReportsPage` (tuiles du hub) | Badge verrou affiché si `ready && !allowed` ; les items restent **cliquables** (la route montre l'upsell — verrouillé visible, jamais masqué). `NAV_ITEMS[].feature?` porté par `budget` / `adjustments` / `balance` uniquement (invariant testé : `reports` n'est jamais gaté, le hub est Free) |
|
||||
| Gate multi-profils | `src/shared/profileGate.ts` | Prédicats purs `isProfileSwitchLocked` / `isProfileCreationLocked` (`multi-profile`, Base+) : le profil **actif** n'est jamais verrouillé, la création est gatée au point unique `ProfileFormModal` (mode upsell compact) ; `ProfileSelectionPage` marque l'entrée « Créer » sans verrouiller les tuiles. Non destructif : aucune donnée supprimée, un upgrade fait tout réapparaître |
|
||||
| Gate multi-profils | `src/shared/profileGate.ts` | Prédicats purs `isProfileSwitchLocked` / `isProfileCreationLocked` (`multi-profile`, Base+) : le profil **actif** n'est jamais verrouillé, la création est gatée au point unique `ProfileFormModal` (mode upsell, qui réutilise `UpsellGate`) ; `ProfileSelectionPage` marque l'entrée « Créer » sans verrouiller les tuiles. Non destructif : aucune donnée supprimée, un upgrade fait tout réapparaître |
|
||||
| Gate de la Répartition | `src/shared/splitGate.ts`, `src/components/transactions/SplitButton.tsx` | `splitAccessFor(gate)` → `open` / `locked` / `pending` sur `useEntitlement("adjustments")`, lu par `TransactionsPage` et passé à `TransactionTable` en prop obligatoire (#369). `locked` : cadenas sur le bouton et `UpsellDialog` au lieu de la fenêtre de répartition, pour toute ligne ; `pending` (licence pas `ready`, chargement ou erreur) : ni cadenas ni fenêtre, bouton inactif, `allowed` ne court-circuitant pas `ready`. La fenêtre de répartition n'est rendue que hors `locked`, l'incitation que hors `open`. Non destructif : les répartitions existantes restent comptées et gardent leur icône orange |
|
||||
| `UpsellDialog` | `src/components/shared/UpsellDialog.tsx` | `UpsellGate` dans une fenêtre (`role="dialog"`, `aria-modal`, nom accessible `upsell.title`), pour une action gatée dans une page Free ; `onNavigate` ferme la fenêtre après « J'ai déjà une clé ». Même balisage que la fenêtre d'incitation de `ProfileSwitcher`, pas encore migrée dessus |
|
||||
| Rust | `src-tauri/src/commands/entitlements.rs` | `FEATURE_TIERS` réduit à `auto-update` → Base+ (la matrice UI vit en TS) ; `check_entitlement` = matrice OU `features[]` signés avec court-circuit Free (même CWE-863), entitlements résolus par `license_commands::current_entitlements` |
|
||||
|
||||
Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Le déclassement d'édition est **non destructif** : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide.
|
||||
Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transactions, Catégories, hub `/reports` + `/reports/trends`, Export/Import, Paramètres, Changelog, docs. Seule exception à l'intérieur d'une page Free : le bouton Répartition de la page Transactions, gaté par `adjustments` (#369). Le déclassement d'édition est **non destructif** : les données des modules verrouillés restent en base et redeviennent accessibles avec une clé valide.
|
||||
|
||||
## Commandes Tauri (36)
|
||||
## Commandes Tauri (39)
|
||||
|
||||
### `fs_commands.rs` — Système de fichiers (6)
|
||||
### `fs_commands.rs` — Système de fichiers (7)
|
||||
|
||||
- `scan_import_folder` — Scan récursif de dossier pour fichiers CSV/TXT
|
||||
- `scan_import_folder` — Liste les sous-dossiers directs du dossier d'import (une source chacun) avec leurs fichiers CSV/TXT directs, et les CSV/TXT posés à la racine, ignorés (`root_files`)
|
||||
- `read_file_content` — Lecture avec gestion de l'encodage
|
||||
- `hash_file` — Hash SHA-256 (détection de doublons)
|
||||
- `detect_encoding` — Détection auto (UTF-8, Windows-1252, ISO-8859-15)
|
||||
- `get_file_preview` — Aperçu des N premières lignes
|
||||
- `pick_folder` — Dialogue de sélection de dossier
|
||||
- `create_sample_import_folders` — Dossier d'exemple (#354). Contrat : reçoit le dossier d'import et la date du jour (`YYYY-MM-DD`) ; écrit « Exemple Desjardins » et « Exemple Tangerine », un relevé `releve-<AAAA-MM>.csv` chacun daté des deux mois complets précédents ; rend les deux **noms** créés, pas les chemins. Refuse si le dossier d'import n'existe pas ou si l'un des deux sous-dossiers existe déjà — les deux existences sont vérifiées **avant** la première écriture, donc rien n'est écrasé ni créé à moitié. Gabarits embarqués par `include_str!` depuis `src/__fixtures__/samples/`
|
||||
|
||||
### `export_import_commands.rs` — Export/Import de données (5)
|
||||
|
||||
|
|
@ -321,28 +433,42 @@ Modules Free — jamais gatés, aucune `FeatureKey` : Dashboard, Import, Transac
|
|||
- `read_import_file` — Lecture fichier chiffré
|
||||
- `is_file_encrypted` — Vérification magic SREF
|
||||
|
||||
### `profile_commands.rs` — Gestion des profils (7)
|
||||
### `profile_commands.rs` — Gestion des profils (8)
|
||||
|
||||
Les commandes qui ouvrent une base passent par `profile_db_path` : dossier du plugin (`app_config_dir()`) et nom de fichier validé (`simpl_resultat.db` ou `profile_<hex>.db`, #390).
|
||||
|
||||
- `load_profiles` — Chargement depuis `profiles.json`
|
||||
- `save_profiles` — Sauvegarde de la configuration
|
||||
- `delete_profile_db` — Suppression du fichier de base de données
|
||||
- `get_new_profile_init_sql` — Récupération du schéma consolidé
|
||||
- `delete_profile_db` — Suppression de la base d'un profil secondaire, de ses fichiers annexes SQLite (`-wal`, `-shm`, `-journal`) et de ses copies `migration-backups/` (#390)
|
||||
- `get_new_profile_init_sql` — Schéma consolidé suivi de l'historique complet des migrations (`history_sql`, #390)
|
||||
- `hash_pin` — Hachage Argon2id du PIN (format `argon2id:salt:hash`)
|
||||
- `verify_pin` — Vérification du PIN (supporte Argon2id et legacy SHA-256 pour rétrocompatibilité)
|
||||
- `repair_migrations` — Réparation des checksums de migration (rusqlite)
|
||||
- `prepare_profile_db` — Avant chaque ouverture : rattrapage des migrations d'un profil secondaire (copie préalable) et réparation des drapeaux de classement manuel de tout profil. Rend un rapport sans chemin (`ProfilePreparation`). Synchrone, donc sérialisée sur le fil principal — [ADR 0021](adr/0021-migrations-profils-secondaires.md)
|
||||
|
||||
### `license_commands.rs` — Licence et activation machine (10)
|
||||
### `license_commands.rs` — Licence et activation machine (11)
|
||||
|
||||
- `validate_license_key` — Validation offline d'une clé de licence (JWT Ed25519)
|
||||
- `store_license` — Stockage de la clé dans le répertoire app data
|
||||
- `store_activation_token` — Stockage du token d'activation
|
||||
- `read_license` — Lecture de la licence stockée
|
||||
- `get_edition` — Détection de l'édition active (free/base/premium)
|
||||
- `get_machine_id` — Génération d'un identifiant machine unique
|
||||
- `activate_machine` — Activation en ligne (appel API serveur de licences, issue #49)
|
||||
- `deactivate_machine` — Désactivation d'une machine enregistrée
|
||||
- `list_activated_machines` — Liste des machines activées pour la licence
|
||||
- `get_activation_status` — État d'activation de la machine courante
|
||||
- `remove_license` — Retrait de la clé enregistrée (#370) : `license.key` d'abord, puis `activation.token` ; `license.check` est gardé. Avec `release_slot`, libère d'abord la place de cette machine (`/v1/licenses/deactivate`, identifiant du jeton) quand le jeton active ce poste et que la clé n'est pas connue comme révoquée. `no_active_activation`, `license_not_found` et `license_revoked` (enregistré pour la clé envoyée) valent place libre ; toute autre erreur de la libération est rendue sans rien supprimer. Idempotente sans clé
|
||||
- `store_activation_token` — Stockage du token d'activation, qui doit avoir été émis pour l'identifiant dérivé de cette machine
|
||||
- `read_license` — Lecture de la licence stockée, avec `revoked` : l'état de révocation enregistré pour cette clé (#365)
|
||||
- `get_edition` — Détection de l'édition active (free/base/premium), depuis la seule clé (#347) et son état de révocation (#365)
|
||||
- `activate_machine` — Activation en ligne (`POST /v1/licenses/activate`, issue #49, chemins `/v1` + champ `product` depuis #270). Envoie l'identifiant machine **dérivé**, jamais celui du système (#350)
|
||||
- `deactivate_machine` — Désactivation d'une machine enregistrée (`/v1/licenses/deactivate`). Idempotente : `no_active_activation` (place déjà libre côté serveur) vaut succès. Supprime le jeton local quand il représentait la place libérée
|
||||
- `list_activated_machines` — Liste des machines activées (`/v1/licenses/verify`). Une réponse `valid:false` (clé introuvable, révoquée ou expirée) est une erreur codée, plus une liste vide. Chaque ligne porte `is_this_machine`, calculé côté Rust. Rend `{ machines, token_dropped }` depuis #367 : une liste complète qui ne nomme pas ce poste supprime le jeton d'activation de ce poste (`token_dropped: "not_listed"`, sinon `null`)
|
||||
- `get_activation_status` — État d'activation de la machine courante (`is_activated` seul, sans identifiant). Validation stricte du jeton : expiré ou émis pour une autre machine, il se lit « non activée » et la carte propose « Activer »
|
||||
- `check_license_revocation` — Contrôle de révocation, au démarrage (`POST /v1/licenses/verify`, #365) et à la saisie d'une clé (#373), un à la fois. Rend `{ outcome, revoked }` et ne rejette jamais sur une panne du serveur : `no_license` et `not_due` signifient qu'aucune requête n'est partie ; `storage`, que la tentative n'a pas pu être écrite, et rien n'est parti, ou que le verdict du serveur ne l'a pas pu, et rien n'a changé
|
||||
|
||||
Contrat d'erreur des commandes réseau (#270) : `LicenseError`, sérialisé en JSON dans le `Err` comme `FetchPriceError` — `{"code":"license_revoked"}`, `{"code":"rate_limit","retry_after_s":42}`, `{"code":"server","status":502}`. Seul `error.code` de l'enveloppe `/v1` décide : un 404 est `license_not_found` ou `no_active_activation`, jamais une route absente. Le corps d'un non-2xx est lu sans exiger de JSON, donc une page HTML 502 donne `server`. Les appels vivent dans des fonctions `*_inner` sans `AppHandle`, testées contre un serveur mockito qui ne déclare que les routes `/v1`. Depuis #347, les commandes de clé suivent le même contrat : `invalid_prefix`, `invalid_signature`, `malformed`, `unknown_edition`, `license_expired` (clé expirée) et `storage` (fichier illisible ou non inscriptible). Leur logique vit dans `store_license_in` / `read_license_in`, qui prennent un dossier et se testent dans un dossier temporaire. `store_license_in` supprime `activation.token` quand la clé enregistrée change : le jeton désignerait l'ancienne licence auprès du serveur des cours, et la carte afficherait « Activée ». Le retrait de la clé (#370) vit dans `remove_license_in`, sans `AppHandle` : la libération préalable (`release_this_machine_slot`) passe par `deactivate_idempotent` puis `note_revocation`, et non par `deactivate_in`, qui supprimerait le jeton avant la clé. Toute suppression du jeton passe par `remove_activation_token_in`, qui tolère un jeton absent et rend `storage` sinon ; `store_license_in` et la désactivation l'emploient aussi. Les deux suppressions de #367, décidées par la réponse d'un serveur, passent par `remove_activation_token_if_unchanged_in`, qui ne supprime le fichier que s'il vaut encore le jeton dont parle la réponse : une activation arrivée pendant la requête écrit un jeton neuf, qui reste (CWE-367).
|
||||
|
||||
Résolution de l'édition (#347, D12) : `current_entitlements` lit la seule `license.key` du dossier de données (`entitlements_from_dir`) ; une clé valide et non expirée donne son édition et ses `features[]`, quel que soit `activation.token`, sauf si `license.check` la dit révoquée (#365, paragraphe suivant). Le jeton lie la clé à la machine **côté serveur** seulement : il sert à l'état « Activée » de la carte et aux cours du Bilan, que `/v1/prices` vérifie. L'état « Activée » est décidé par `activation_status_in` (signature, `machine_id`, `exp`), testée dans un dossier temporaire. Voir l'amendement #347 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
|
||||
|
||||
Révocation (#365) : `license.check`, dans le dossier de données, garde ce que le serveur de licences a dit de la clé enregistrée, en JSON `{key_sha256, attempted_at, revoked}`. La clé n'y figure que par son condensé SHA-256. Un fichier absent, illisible, invalide ou qui parle d'une autre clé se lit « pas d'état » (`read_license_check`) : la clé n'est pas tenue pour révoquée et un contrôle est dû. `entitlements_from_dir` rend `("free", [])` quand l'état dit `revoked`, ce qui verrouille aussi `auto-update`. Le contrôle vit dans `check_revocation_in`, sans `AppHandle` : rien ne part sans une clé qui se valide localement, ni quand la dernière tentative a moins de 24 h (`revocation_check_due`, qui traite une date dans le futur comme due) ; `attempted_at` est écrit avant l'appel, donc une tentative échouée compte ; seule une réponse `valid:true` lève l'état et seule une réponse « révoquée » le pose (`verdict_from_verify` ne lit que `valid` et `reason`), tout le reste le laisse tel quel. Une clé connue comme révoquée reste contrôlée au même rythme, pour reprendre une correction faite côté serveur (décision du 2026-10-03), tant qu'elle est enregistrée : `remove_license` (#370) la retire et garde `license.check`, si bien qu'une clé révoquée retirée puis ressaisie reste en Gratuite. La requête porte `product` et `license_key`, rien d'autre. Le front la lance au démarrage et à la saisie d'une clé (#373), donc deux contrôles peuvent se chevaucher : la commande passe par `check_revocation_in_turn`, qui prend le verrou statique `REVOCATION_CHECK_TURN` (`tauri::async_runtime::Mutex`) avant de lire l'état et le garde jusqu'au verdict, et ne lit l'heure qu'une fois son tour venu. Un second contrôle de la même clé lit la tentative du premier et n'envoie rien ; celui d'une clé saisie pendant le contrôle d'une autre attend ce verdict au lieu de l'écraser dans `license.check`. L'activation, la liste des machines et la désactivation passent par `note_revocation`, qui enregistre un `license_revoked` sous la clé envoyée (`activate_and_store_in`, `list_machines_in`, `deactivate_in`). La route des cours fait exception : `fetch_price_inner` (`balance_commands.rs`) lit `error.code` d'un 403 et rend `FetchPriceError::LicenseRevoked` au lieu de `PremiumRequired`, et `fetch_price_in` supprime alors le jeton d'activation refusé, s'il n'a pas changé pendant la requête, mais n'écrit pas l'état de la clé : le serveur y juge la licence du jeton, que le client ne sait pas relier à la clé enregistrée, et ce jeton ne sert plus à rien quelle que soit la clé (#367). `fetch_price_in` lit le jeton dans le dossier de données (`read_stored_activation_token_in`, constante `ACTIVATION_FILE`) et se teste dans un dossier temporaire. Voir l'amendement #365 de l'[ADR 0017](adr/0017-feature-gating-par-tier.md).
|
||||
|
||||
Identifiant machine (#350, [ADR 0020](adr/0020-identifiant-machine-derive.md)) : le serveur de licences ne reçoit plus l'identifiant du système (`machine_uid::get()` : `/etc/machine-id`, `MachineGuid`), que machine-id(5) tient pour confidentiel, mais `derive_machine_id`, soit `HMAC-SHA256` sous la clé fixe `com.simpl.resultat/license-machine-id/v1`, rendu en 64 caractères hexadécimaux. La clé et la formule sont figées, épinglées par deux vecteurs de référence ; un identifiant vide est refusé, jamais haché. `MachineIds` porte les deux formes de l'identifiant de cette machine, la dérivée et la brute, et la brute ne sert plus qu'à reconnaître ce qui date d'avant la dérivation : un jeton dont le claim la porte se lit toujours « Activée », et une ligne de la liste qui la porte est toujours marquée `is_this_machine`. Seule l'activation envoie un identifiant de cette machine (`activate_and_store_in`, qui dérive juste avant l'appel et exige que le jeton reçu porte le dérivé) ; la désactivation renvoie au serveur l'identifiant de la ligne choisie. `drop_token_of_deactivated_machine_in` supprime ensuite le jeton local s'il représentait la place libérée : toute ligne de cette machine l'emporte, sauf l'ancienne ligne brute quand le jeton vaut pour la ligne dérivée, restée active. `drop_token_of_unlisted_machine_in` (#367), appelée par `list_machines_in`, supprime le jeton quand la liste de la clé enregistrée ne nomme ce poste sous aucune des deux formes, si l'identifiant du système est lisible, si la liste est complète (`machines_from_verify` compte les lignes écartées, que l'affichage tolère toujours), si le jeton active ce poste et s'il n'a pas changé pendant la requête ; une erreur, une clé révoquée ou un 429 ne suppriment rien. Elle suppose que le serveur, s'il rehache un jour ses lignes brutes (maximus/maximus-api#82), produit exactement le dérivé ci-dessus, ce qu'un test épingle. Un identifiant du système illisible ne bloque ni la liste ni la désactivation, et se lit « non activée ». Plus aucune commande ne lit l'identifiant du système pour le remettre au webview ; la liste du serveur porte encore celui d'une ligne antérieure à la dérivation, jusqu'à la réactivation. La compatibilité avec l'identifiant brut a une condition de retrait, consignée dans l'ADR.
|
||||
|
||||
Le lien d'achat, `/{fr|en}/produits/simpl-resultat` (`src/shared/purchaseUrl.ts`), utilisé par la carte Licence, `PurchaseButton` et `DownloadLatestButton`, est un contrat avec le site : il est gravé dans des binaires qui ne se mettent plus à jour en édition gratuite (voir maximus/la-compagnie-maximus#153).
|
||||
|
||||
### `auth_commands.rs` — Compte Maximus / OAuth2 PKCE (5)
|
||||
|
||||
|
|
@ -370,14 +496,14 @@ Module privé appelé uniquement par `auth_commands.rs` et `license_commands.rs`
|
|||
|
||||
### `entitlements.rs` — Entitlements (1)
|
||||
|
||||
- `check_entitlement` — Vérifie si une feature est autorisée : `is_feature_allowed` (matrice statique) OU présence dans le `features[]` signé de la licence, avec **court-circuit fail-closed en Free** (CWE-863 : une clé copiée, déclassée `free` par le machine-binding, ne récupère jamais ses features signées)
|
||||
- `check_entitlement` — Vérifie si une feature est autorisée : `is_feature_allowed` (matrice statique) OU présence dans le `features[]` signé de la licence, avec **court-circuit fail-closed en Free** (CWE-863 : une clé résolue en `free`, invalide, expirée ou révoquée, ne récupère jamais ses features signées)
|
||||
- Source de vérité Rust : `FEATURE_TIERS` dans `entitlements.rs`, réduit depuis #301 à `auto-update` → `[base, premium]` (absorbe l'issue #271). Les gates UI (`budget`, `adjustments`, `reports-advanced`, `multi-profile`, `balance`) vivent dans la matrice TS `src/shared/entitlements.ts` — voir la section « Gating par édition » et l'[ADR 0017](adr/0017-feature-gating-par-tier.md)
|
||||
- Édition et `features[]` sont résolus ensemble par `license_commands::current_entitlements` (interne, pas une commande) : même chemin machine-binding que `get_edition`, tout échec de validation retourne `("free", [])`
|
||||
- Édition et `features[]` sont résolus ensemble par `license_commands::current_entitlements` (interne, pas une commande) : même chemin que `get_edition`, depuis la seule clé (#347) et son état de révocation (#365), tout échec de validation retourne `("free", [])`
|
||||
- Dev : la Cargo feature `dev-override` (off par défaut — feature explicite, pas `debug_assertions`, CWE-489) compile la lecture de `SR_DEV_EDITION` pour forcer l'édition résolue en test (`cargo test --features dev-override`)
|
||||
|
||||
### `balance_commands.rs` — Bilan (1)
|
||||
|
||||
- `compute_account_return(account_id, period_start, period_end, db_filename)` — Calcul Modified Dietz d'un compte sur une période. Ouvre une connexion `rusqlite` courte sur le fichier DB du profil actif, lit le snapshot ≤ `period_start`, le snapshot ≥ `period_end` et tous les `balance_account_transfers` JOIN `transactions` dans la fenêtre, puis appelle `return_calculator::modified_dietz`. Retourne `AccountReturn { value_start, value_end, net_contributions, return_pct, annualized_pct, is_partial, has_no_transfers_warning }`. Voir [ADR 0008](adr/0008-modified-dietz-pour-rendement.md).
|
||||
- `compute_account_return(account_id, period_start, period_end, db_filename)` — Calcul Modified Dietz d'un compte sur une période. Ouvre une connexion `rusqlite` courte sur le fichier DB du profil actif (résolu par `profile_db_path` ; sous Linux, l'ancien chemin `app_data_dir()` faisait échouer le calcul, #390), lit le snapshot ≤ `period_start`, le snapshot ≥ `period_end` et tous les `balance_account_transfers` JOIN `transactions` dans la fenêtre, puis appelle `return_calculator::modified_dietz`. Retourne `AccountReturn { value_start, value_end, net_contributions, return_pct, annualized_pct, is_partial, has_no_transfers_warning }`. Voir [ADR 0008](adr/0008-modified-dietz-pour-rendement.md).
|
||||
|
||||
Le module privé `return_calculator.rs` (déclaré dans `commands/mod.rs` mais non exposé comme commande) contient la logique pure Modified Dietz et ses tests `#[cfg(test)] mod tests` co-localisés (TDD, 7 cas : nominal / pas de snapshot début / partial / créé en cours / vidé / sans transferts / annualisation).
|
||||
|
||||
|
|
@ -391,10 +517,10 @@ Ordre d'initialisation dans `lib.rs` (certains plugins ont des contraintes d'ord
|
|||
|--------|------|-----------|
|
||||
| `tauri-plugin-single-instance` | Empêche les doubles lancements et forwarde les URLs deep-link au processus existant | **Doit être le premier plugin** ; feature `deep-link` requise pour le forwarding d'URL |
|
||||
| `tauri-plugin-opener` | Ouverture d'URLs externes et de fichiers | — |
|
||||
| `tauri-plugin-dialog` | Dialogues de sélection de fichier/dossier | — |
|
||||
| `tauri-plugin-dialog` | Dialogues de sélection de fichier/dossier ; confirmations par `ask` (`confirmService.ts`, paquet JS `@tauri-apps/plugin-dialog`, #421) | Sur desktop, son script d'initialisation remplace `window.confirm` par une fonction asynchrone et `window.alert` par une fonction non bloquante : jamais de `confirm` / `alert` natif (garde `native-dialog-guard.test.ts`) ; paquet JS à la version du crate |
|
||||
| `tauri-plugin-process` | Relaunch après mise à jour | — |
|
||||
| `tauri-plugin-deep-link` | Gère le scheme custom `simpl-resultat://` | Doit être initialisé avant `setup()` pour que `on_open_url` soit disponible |
|
||||
| `tauri-plugin-updater` | Mise à jour auto (gated par entitlement `auto-update`) | Initialisé dans `setup()` derrière `#[cfg(desktop)]` |
|
||||
| `tauri-plugin-updater` | Mise à jour auto (gated par entitlement `auto-update`) | Initialisé dans `setup()` derrière `#[cfg(desktop)]` ; compilé sans ses features par défaut (`rustls-tls` seule, #455) : aucune extraction d'archive sous Windows et Linux |
|
||||
| `tauri-plugin-sql` | SQLite + migrations | Doit être initialisé avec les migrations pour que le schéma soit prêt |
|
||||
|
||||
## OAuth2 et deep-link (Compte Maximus)
|
||||
|
|
@ -427,9 +553,10 @@ Les modules payants sont enveloppés dans des **layout-routes pathless `RequireF
|
|||
|
||||
- **`ErrorBoundary`** (class component) : wrape `<App />` dans `main.tsx`, attrape les crashs React et affiche `ErrorPage` en fallback
|
||||
- **`ErrorPage`** : page d'erreur réutilisable avec détails techniques (collapsible), bouton "Actualiser", vérification de mises à jour, et liens de contact/issues
|
||||
- **Timeout au démarrage** : `App.tsx` applique un timeout de 10 secondes sur `connectActiveProfile()` — affiche `ErrorPage` au lieu d'un spinner infini si la connexion DB échoue
|
||||
- **Timeout au démarrage** : `App.tsx` appelle `startProfileConnection` (`src/utils/startupConnection.ts`), qui applique un timeout de 10 secondes (`STARTUP_TIMEOUT_MS`) sur `connectActiveProfile()` et réessaie trois fois — affiche `ErrorPage` au lieu d'un spinner infini si la connexion DB échoue ; une ouverture qui aboutit après le délai efface l'erreur et affiche l'application (#395, mesure dans l'ADR 0021)
|
||||
- **Retry au démarrage** : `connectActiveProfile()` réessaie jusqu'à 3 fois avec 1s de délai avant d'afficher l'erreur
|
||||
- **Réparation de migrations** : `repair_migrations` (Rust/rusqlite) supprime les checksums invalides de `_sqlx_migrations` avant le chargement de la DB
|
||||
- **Préparation du profil** : `prepare_profile_db` (Rust/rusqlite) rattrape les migrations d'un profil secondaire et répare les drapeaux texte avant le chargement ; un échec est écrit au Journal, le profil s'ouvre quand même (#390)
|
||||
- **Log viewer** : `logService.ts` capture les `console.log/warn/error` dans un buffer circulaire (500 entrées, persisté en `sessionStorage`), affiché dans la page Paramètres via `LogViewerCard`
|
||||
|
||||
| Route | Page | Description |
|
||||
|
|
@ -467,28 +594,82 @@ Page spéciale : `ProfileSelectionPage` (affichée quand aucun profil n'est acti
|
|||
- **Langue de fallback** : Anglais (`en`)
|
||||
- **Fichiers** : `src/i18n/locales/fr.json`, `src/i18n/locales/en.json`
|
||||
- **Clés organisées** hiérarchiquement par domaine (`nav.*`, `dashboard.*`, `import.*`, etc.)
|
||||
- **Formateur `lowercase`** (`src/i18n/config.ts`) : `{{valeur, lowercase}}` met en minuscules, selon la langue, un libellé interpolé dans une phrase depuis sa propre clé (le filtre « Non catégorisées » dans la carte de démarrage de la page Transactions, #399). La phrase décide de la casse, pas le code.
|
||||
|
||||
## Tests (vitest sans jsdom)
|
||||
|
||||
Le dépôt n'a pas de jsdom : les tests rendent les composants par `renderToStaticMarkup`, ou appellent hooks et composants comme des fonctions pour lire l'arbre rendu et les props. Un rendu statique ne lance aucun effet et aucun clic ; ce qu'un composant fait avec React entre deux rendus passe par un faux module `react`.
|
||||
|
||||
### Harnais de hooks — `src/test/hookHarness.ts` (#409)
|
||||
|
||||
Un seul harnais pour tous les tests qui simulent React. Motif d'import, unique, en tête du fichier de test :
|
||||
|
||||
```ts
|
||||
vi.mock("react", async (orig) => (await import("../test/hookHarness")).fakeReact(await orig()));
|
||||
import { hookHarness } from "../test/hookHarness";
|
||||
const harness = hookHarness();
|
||||
```
|
||||
|
||||
Puis `harness.clear()` dans `beforeEach` (un montage neuf), `harness.render(() => Composant(props))` pour un rendu du composant monté, `harness.commit()` pour jouer ses effets. Les valeurs que le test change entre deux rendus (route, valeur de contexte) vivent dans un objet `vi.hoisted` du test. `hookHarness.ts` n'importe React qu'en `import type` : un import de valeur, depuis la fabrique qui construit le faux `react`, chargerait ce faux.
|
||||
|
||||
Sémantique simulée, chaque point prouvé par `src/test/hookHarness.test.ts` :
|
||||
|
||||
- emplacements par ordre d'appel, curseur remis à zéro à chaque `render()` ; un test peut prérégler l'état d'un `useState` ou d'un `useReducer` avant le premier rendu ;
|
||||
- `useState` : initialiseur appelé une fois, setter stable, mises à jour fonctionnelles ; **aucun nouveau rendu automatique**, le test rend de nouveau quand il veut lire l'état ;
|
||||
- `useReducer` : le reducer est appliqué, `dispatch` est stable, et chaque action est aussi enregistrée par l'espion `harness.dispatch` ;
|
||||
- `useRef` : un objet par emplacement, gardé d'un rendu à l'autre ;
|
||||
- `useMemo` / `useCallback` : recalculés seulement quand une dépendance change (`Object.is`) ;
|
||||
- `useEffect` : capturé pendant le rendu, joué seulement par `commit()` s'il a une dépendance changée, nettoyages d'abord ; `replayEffects()` joue le double passage de StrictMode au montage, `unmount()` les nettoyages ;
|
||||
- tout autre hook (`useContext`, `useLayoutEffect`, `useSyncExternalStore`, `useId`, `use`…) **lève** : un test rougit sur son assertion, jamais par « Invalid hook call ». Mocker le module qui l'appelle (`useOnboarding`, `useLocation`, `useTranslation`…).
|
||||
|
||||
Non simulé : le nouveau rendu après un setter ou un dispatch, les enfants (un composant dans l'arbre rendu reste un élément), le DOM, le concurrent, la boucle de mises à jour pendant le rendu (le `render()` suivant la lit), et un reducer qui change d'un rendu à l'autre : le `dispatch` d'un `useReducer` garde le reducer du premier rendu (il est créé une fois, avec l'emplacement), là où React applique celui du dernier rendu. Un reducer défini au niveau du module, seul cas utilisé, n'est pas touché.
|
||||
|
||||
Utilisateurs : `useOnboardingChecklist.wiring.test.ts`, `UsersSettingsPage.test.tsx`, `BudgetTable.wiring.test.ts`, `BudgetPage.wiring.test.ts`, `AppShell.slot.test.tsx`, `OnboardingStepCard.test.tsx`, `PriceFetchConsentToggle.test.tsx` (#389). Hors périmètre de #409, `useImportWizard.mount.test.ts` et `useImportWizard.executeImport.test.ts` gardent leurs faux non persistants.
|
||||
|
||||
### Faux `t` marqué — `src/test/i18nMock.ts` (#389)
|
||||
|
||||
**Jamais de `t` brut dans un test.** Un faux `t` qui rend la clé telle quelle cache un appel à `t` oublié : un composant qui affiche `license.removeKey.confirmValid` au lieu du texte traduit passe toute la suite. Dans les fichiers de test, le seul faux `t` est `markedT` (`src/test/i18nMock.ts`), servi par la fabrique `i18nMock` ou passé tel quel à une fonction qui prend `t` en paramètre (par exemple `renderCategoryLabel`) : il rend `t(clé)`, ou `t(clé[a=1,b=2])` avec des options, dans l'ordre des options. La garde ci-dessous ne refuse que les formes qu'elle sait reconnaître : un faux `t` écrit autrement lui échapperait. Motif unique, en tête du fichier de test :
|
||||
|
||||
```ts
|
||||
vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());
|
||||
```
|
||||
|
||||
Options de la fabrique : `language` (« fr » par défaut ; une fonction est relue à chaque accès, pour un test qui change de langue entre deux rendus : `i18nMock({ language: () => language })`) et `fixture` (valeurs rendues telles quelles pour certaines clés, comme ce que déclare la locale, un objet sous `returnObjects` par exemple ; une valeur lue par la fabrique au chargement du module se déclare par `vi.hoisted`). Une fonction qui prend `t` en paramètre reçoit `markedT` lui-même.
|
||||
|
||||
Les assertions qui nomment un texte affiché s'écrivent sous la forme marquée (`toContain(">t(dashboard.title)<")`, `toEqual(["t(onboarding.state.done)"])`) : elles rougissent sur une chaîne qui a sauté `t`. Une assertion par sous-chaîne sur une clé nue reste valide, la clé figurant dans la marque, mais ne prouve pas l'appel à `t`. Dans une expression régulière, échapper les parenthèses et crochets de la marque.
|
||||
|
||||
Garde : `src/__integration__/i18n-mock-guard.test.ts` lit tous les `*.test.ts(x)` de `src/` et échoue, hors de `src/test/i18nMock.ts`, sur une propriété `useTranslation:`, sur un faux `t` en propriété (`t: (`, `t: vi.fn(`) ou en liaison (`t = (`, `t = vi.fn(`), tous ancrés sur une frontière de mot, ou sur un `vi.mock("react-i18next", …)` sans appel à `i18nMock(` (le nom seul, dans un commentaire par exemple, ne compte pas : les commentaires sont retirés avant la recherche).
|
||||
|
||||
## CI/CD
|
||||
|
||||
Quatre workflows Forgejo Actions dans `.forgejo/workflows/`. Le runner est à **capacité 1** : les jobs se suivent, ils ne tournent pas en parallèle.
|
||||
|
||||
### `check-rust.yml` — Vérifications Rust sur PR
|
||||
### `check-rust.yml` — Vérifications Rust sur PR et chaque semaine
|
||||
|
||||
Déclenché sur les PR qui touchent `src-tauri/**`, `.cargo/**` (ou le workflow lui-même). Lance `cargo check` puis `cargo test`, et un `cargo audit` informatif (non bloquant, binaire pré-buildé via `taiki-e/install-action`).
|
||||
Déclenché sur les PR qui touchent `src-tauri/**`, `.cargo/**` (ou le workflow lui-même), chaque lundi à 06:30 UTC sur `main` et par `workflow_dispatch`. Lance `cargo check` puis `cargo test`, et un `cargo audit` informatif (non bloquant, binaire pré-buildé via `taiki-e/install-action`).
|
||||
|
||||
Entre `cargo check` et `cargo test`, une étape **bloquante** rejoue la justification des advisories acceptées dans `.cargo/audit.toml` : elle vérifie par `cargo tree --locked` que chaque crate supprimé reste absent des deux cibles livrées, et échoue si l'un d'eux redevient atteignable. Un canari (`tar`, réellement présent) garantit que son silence prouve quelque chose. Voir [ADR 0018](adr/0018-suppression-advisories-non-atteignables.md).
|
||||
Entre `cargo check` et `cargo test`, une étape **bloquante** rejoue la justification des advisories acceptées dans `.cargo/audit.toml` : elle vérifie par `cargo tree --locked` que chaque crate supprimé reste absent des deux cibles livrées, et échoue si l'un d'eux redevient atteignable. Un canari (`webkit2gtk`, la webview Linux de Tauri, présente sous Linux et absente sous Windows ; `tar` jusqu'à #455) garantit que son silence prouve quelque chose, et que `--target` résout bien les dépendances conditionnelles. Voir [ADR 0018](adr/0018-suppression-advisories-non-atteignables.md).
|
||||
|
||||
Aucun `branches:` : une PR stackée sur une autre branche de feature — ce que produit `/autopilot` — déclenche donc bien la CI. Aucune étape de cache non plus : le conteneur de job n'atteint pas le serveur de cache du runner ([#234](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/234)), le restore et le save échouent tous les deux. Le cache reviendra via `Swatinem/rust-cache` une fois #234 réglé.
|
||||
Juste après, une seconde étape **bloquante**, `Verify windows-sys pinning` ([#345](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/345)), lit `src-tauri/Cargo.lock` avec `awk`. Les sept crates qui déclarent `windows-sys` par une borne couvrant plusieurs mineures (`colored`, `dirs-sys`, `errno`, `rustix`, `rustls-platform-verifier`, `tempfile`, `winapi-util`) doivent tous pointer `0.61.2`, l'état de chaque release Windows depuis v0.14.0, et l'ensemble des versions de `windows-sys` du lock ne doit pas changer. Une mise à jour comme `cargo update -p hyper` re-résout ces arêtes sans que cargo l'annonce, et le build Windows compile alors cinq versions de `windows-sys` au lieu de trois. Chaque vérification est loggée avec un bilan, un canari (`tempfile`) doit être lu et vérifié, et une panne d'`awk` fait échouer l'étape. Quand elle rougit, on remet les arêtes à la main : procédure et condition de sortie dans l'[ADR 0023](adr/0023-windows-sys-pinning.md).
|
||||
|
||||
Aucun `branches:` : une PR stackée sur une autre branche de feature — ce que produit `/autopilot` — déclenche donc bien la CI.
|
||||
|
||||
**Cache** ([#234](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/234)) : `Swatinem/rust-cache` garde `~/.cargo` et les dépendances compilées de `src-tauri/target`, sous une clé faite de la version de rustc, des variables `CARGO*` et `RUST*` du job, des dépendances déclarées dans les manifestes et le lockfile Cargo (la version de l'app n'en fait pas partie), et du job. Il passe par le proxy de cache du runner, sur le port 28100 qu'ufw n'ouvre qu'aux réseaux Docker des jobs (procédure et retour arrière dans `docs/forgejo-ops.md` de la-compagnie-maximus). Le runner (v12.7.0) garde ce qu'écrit un run de PR pour cette seule PR : ses pushes suivants le relisent, les autres PR non. Le run du lundi et `workflow_dispatch` sont donc les seuls à écrire le cache partagé, sur lequel une PR se rabat quand rien ne correspond dans sa propre portée. Le runner supprime une entrée inutilisée depuis 7 jours ou créée il y a plus de 30 jours, et ne garde qu'une entrée par clé, toutes portées confondues. Un run à froid est donc normal dans les cas suivants ; seul un délai dépassé signale un retour de #234 :
|
||||
|
||||
- après une nouvelle version stable de Rust ou une modification de l'`env:` du job, jusqu'au lundi suivant ;
|
||||
- quand une PR sauvegarde sous la clé de l'entrée partagée : elle l'évince pour toutes les PR jusqu'au lundi suivant. Ça arrive quand sa propre portée contient une entrée plus ancienne pour un autre `Cargo.lock`, servie avant l'entrée partagée, par exemple une PR qui a monté une dépendance puis est revenue au lockfile de `main`. Deux PR ouvertes sur un même lockfile, différent de celui de `main`, s'évincent l'une l'autre ;
|
||||
- quand une entrée qui n'a eu que des correspondances exactes, donc jamais re-sauvegardée, atteint 30 jours ;
|
||||
- un lundi après une semaine sans requête au cache, la restauration peut échouer en 404 : la recherche ne rafraîchit pas l'entrée, et le ménage qu'elle déclenche peut la supprimer ([forgejo/runner#1741](https://code.forgejo.org/forgejo/runner/issues/1741)). Le run repart alors à froid et re-sauvegarde.
|
||||
|
||||
### `check-frontend.yml` — Vérifications frontend sur PR
|
||||
|
||||
Déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc ou du markdown (`paths-ignore`). Lance `npm ci`, `npm run build` (tsc + vite) et `npm test` (vitest). Le cache npm est retiré pour la même raison que côté Rust.
|
||||
Déclenché sur les PR, sauf si tous les fichiers modifiés sont du Rust, de la doc, du markdown, `check-rust.yml` ou `audit.yml` (`paths-ignore`). `.claude/**` et `release.yml` le déclenchent ([#419](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/419)) : `scripts/build-latest-json.test.mjs` lit la liste de clés de `verify-release.sh` et exécute le texte de l'étape « Publish latest.json to package registry ». Lance `npm ci`, `npm run build` (tsc + vite) et `npm test` (vitest). Installe aussi `jq`, dont a besoin `scripts/build-latest-json.test.mjs` : ce test lance le script de génération de `latest.json` de `release.yml` ([#320](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/320)). Pas de cache npm : ce workflow ne tourne que sur des PR, donc un cache ne servirait qu'aux pushes suivants d'une même PR, et `npm ci` y prend 9 s.
|
||||
|
||||
**Aucune étape `npm audit`** — contrairement au Rust, les advisories npm ne sont donc pas un gate de CI. Conséquence à connaître : `npm audit` remonte **2 high en permanence**, les deux clés d'une même advisory `react-router` ([GHSA-qwww-vcr4-c8h2](https://github.com/advisories/GHSA-qwww-vcr4-c8h2), mode React Server Components). Elle est acceptée : l'app est un client de bureau sans serveur — `App.tsx` monte un `BrowserRouter` client-only — et `react-router-dom` étant figé en 7.18.1, sortir de la plage vulnérable demanderait une migration vers react-router v8, pas un bump. Déclencheur de re-évaluation : [#317](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/317). `npm audit` n'a pas de mécanisme d'exclusion natif, il n'y a donc pas d'équivalent du `.cargo/audit.toml` côté npm.
|
||||
**Aucune étape `npm audit`** — contrairement au Rust, les advisories npm ne sont donc pas un gate de CI. `npm audit` est à 0 au 2026-10-10 ([#358](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/358)), après un rafraîchissement du lock dans les bornes de `package.json`. Ce 0 est un constat daté, pas un état garanti : aucun job ne le fait échouer, mais l'étape `npm ci` de `check-frontend` audite à chaque run et en imprime le compte (`found 0 vulnerabilities`, ou le nombre trouvé), et le Défenseur relève les advisories de son côté (#358 en vient). `npm audit` n'a pas de mécanisme d'exclusion natif, il n'y a donc pas d'équivalent du `.cargo/audit.toml` côté npm.
|
||||
|
||||
### `audit.yml` — Audit RustSec quotidien
|
||||
|
||||
`schedule` quotidien (06:00 UTC) + `workflow_dispatch`. `check-rust.yml` ne tournant que sur les PR qui touchent le Rust — environ 1 PR sur 40 — ce workflow garantit qu'un avis de sécurité publié sur une dépendance inchangée ne passe pas inaperçu. Échec bloquant : c'est le canal de notification.
|
||||
`schedule` quotidien (06:00 UTC) + `workflow_dispatch`. `check-rust.yml` ne tournant que sur les PR qui touchent le Rust et une fois par semaine, avec un `cargo audit` non bloquant, ce workflow garantit qu'un avis de sécurité publié sur une dépendance inchangée ne passe pas inaperçu. Échec bloquant : c'est le canal de notification.
|
||||
|
||||
**Un run vert ne signifie pas « zéro advisory »** mais « zéro advisory hors de la liste acceptée ». Cette liste vit dans `.cargo/audit.toml`, à la racine du dépôt, avec pour chaque entrée sa preuve de non-atteignabilité et sa condition de retrait ; la politique qui l'encadre est l'[ADR 0018](adr/0018-suppression-advisories-non-atteignables.md). Au moment de sa mise en place, le `schedule` n'avait encore jamais déclenché ce workflow ([#314](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/314)) — un `workflow_dispatch` vert prouve que la commande sort en 0, pas que l'alarme quotidienne fonctionne.
|
||||
|
||||
|
|
@ -498,14 +679,17 @@ Les deux workflows `check-*` doivent être verts avant tout merge. Ils évitent
|
|||
|
||||
### `release.yml` — Build et publication
|
||||
|
||||
Déclenché par les tags `v*`. Deux jobs :
|
||||
1. **build-windows** (windows-latest) → Installeur `.exe` (NSIS)
|
||||
2. **build-linux** (ubuntu-22.04) → `.deb` + `.rpm`
|
||||
Déclenché par les tags `v*`. Un seul job, `build-and-release`, dans un conteneur `ubuntu:22.04` :
|
||||
1. Linux → `.deb` + `.rpm`
|
||||
2. Windows, compilé depuis Linux par `cargo-xwin` → installeur `.exe` (NSIS)
|
||||
|
||||
Fonctionnalités :
|
||||
- Signature des binaires (clés TAURI_SIGNING_PRIVATE_KEY)
|
||||
- JSON d'updater publié sur `https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json`
|
||||
- Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md
|
||||
- JSON d'updater (`latest.json`) écrit par `scripts/build-latest-json.sh` à partir des assets ([#320](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/320)). `tauri-plugin-updater` cherche `{os}-{arch}-{installer}`, puis `{os}-{arch}` : le fichier porte `linux-x86_64-deb` et `linux-x86_64-rpm` (une clé par paquet Linux) et `windows-x86_64` (l'installeur NSIS). Pas de clé générique `linux-x86_64` depuis le 2026-10-11 : elle servait le `.deb` à des installations qui ne savaient pas l'installer (anciennes AppImage, binaire hors paquet), qui reçoivent désormais `TargetsNotFound` à la vérification (raisons et lecteurs dans `docs/qa-update-cycle.md`). Le script échoue s'il ne trouve pas exactement un `.deb`, un `.rpm` et un `-setup.exe`, chacun avec un `.sig` non vide ; le JSON vient de `jq` seul (notes lues comme données par `--rawfile`), avec les noms de fichiers encodés par `@uri`, comme à l'envoi des assets. Testé par `scripts/build-latest-json.test.mjs` (vitest, lancé par `check-frontend`), qui l'exécute sous `bash -e -o pipefail` comme `release.yml`.
|
||||
- JSON publié sur `https://git.lacompagniemaximus.com/api/packages/maximus/generic/simpl-resultat/latest/latest.json` (registre de paquets, endpoint de `tauri.conf.json`). Avant le `DELETE` de l'ancienne version, le filtre `scripts/check-latest-json.jq` (version égale au tag, exactement les trois clés, signatures non vides, extension de chaque URL) arrête le job si le fichier ne le passe pas : le registre reste intact. Il est appelé avec `-s` et n'accepte qu'un seul document, si bien qu'un fichier vide ou à deux documents est refusé sous jq 1.6 comme sous 1.7 ([#419](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/419)). Le test du script applique ce même filtre à chaque PR, compare sa liste de clés à celles du script et de `verify-release.sh`, et exécute le texte de l'étape « Publish » avec un `curl` factice (`DELETE` puis `PUT` sur un manifeste valide, aucun appel sur un manifeste refusé), si bien qu'un écart se voit avant le tag.
|
||||
- Release Forgejo automatique avec assets et release notes extraites du CHANGELOG.md, transmises par `GITHUB_OUTPUT` sous un délimiteur aléatoire (une ligne du CHANGELOG égale à un délimiteur fixe terminait la valeur)
|
||||
|
||||
**Node.js 24** ([#376](https://git.lacompagniemaximus.com/maximus/simpl-resultat/issues/376)), installé comme dans les trois autres workflows par le script NodeSource (`curl … | bash`). Ce script tourne en root dans le conteneur du job, et le Node.js qu'il installe lance ensuite `npx tauri build` avec `TAURI_SIGNING_PRIVATE_KEY` dans son environnement : le job confie à NodeSource (script et dépôt apt) la clé de signature de l'updater, et les jetons de publication des étapes suivantes. Risque accepté par Max le 2026-10-10, alternative écartée : un tarball Node.js épinglé. La chaîne Rust, installée de la même façon par `rustup`, est dans le même cas. Ce workflow ne tourne qu'au tag : la première release après #376 est la première sous Node 24, à vérifier par `node --version` dans le journal du run (étape 9 de `/release`).
|
||||
|
||||
## Architecture Decision Records (ADRs)
|
||||
|
||||
|
|
@ -532,3 +716,8 @@ Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `d
|
|||
| [0017](adr/0017-feature-gating-par-tier.md) | Gating des fonctionnalités par édition : matrice UI statique, override signé fail-closed, soft-paywall assumé | 2026-07-20 | Accepted |
|
||||
| [0018](adr/0018-suppression-advisories-non-atteignables.md) | Suppression d'advisories non atteignables : admission sur preuve par cible livrée, clé par ID, garde-fou bloquant | 2026-07-27 | Accepted |
|
||||
| [0019](adr/0019-format-import-persiste.md) | Le format d'import est une donnée persistée intégralement, jamais re-devinée | 2026-08-13 | Accepted |
|
||||
| [0020](adr/0020-identifiant-machine-derive.md) | Identifiant machine dérivé : un hachage à clé fixe remplace l'identifiant du système auprès du serveur de licences | 2026-10-01 | Accepted |
|
||||
| [0021](adr/0021-migrations-profils-secondaires.md) | Les profils secondaires rattrapent leurs migrations à l'ouverture | 2026-10-06 | Accepted |
|
||||
| [0022](adr/0022-canal-evenements-donnees.md) | Canal d'événements de données : les écritures signalent, le parcours de démarrage relit | 2026-10-08 | Accepted |
|
||||
| [0023](adr/0023-windows-sys-pinning.md) | Épinglage de `windows-sys` 0.61.2 : garde-fou CI sur la version résolue, condition de sortie écrite | 2026-10-10 | Accepted |
|
||||
| [0024](adr/0024-serveur-mcp.md) | Serveur MCP : un mode `--mcp` du binaire, stdio seul, accès par profil, Premium | 2026-10-12 | Proposed |
|
||||
|
|
|
|||
|
|
@ -16,16 +16,34 @@ Simpl'Résultat vous aide à suivre vos finances personnelles en important des r
|
|||
- Profils multiples avec bases de données séparées et NIP optionnel
|
||||
- Mode sombre avec palette gris chaud
|
||||
- Export et import de données avec chiffrement AES-256 optionnel
|
||||
- Parcours de démarrage guidé : un bandeau en haut de chaque page et, sur la page de l'étape en cours, une carte qui dit quoi faire
|
||||
|
||||
### Démarrage rapide
|
||||
|
||||
1. Au premier lancement, choisissez ou créez un profil — chaque profil a sa propre base de données
|
||||
2. Allez dans Paramètres et définissez votre dossier d'import — créez un sous-dossier par compte bancaire
|
||||
3. Placez vos relevés bancaires CSV dans le sous-dossier correspondant
|
||||
4. Ouvrez la page Import et configurez votre source (mapping des colonnes, délimiteur, format de date)
|
||||
5. Importez vos transactions, puis allez dans Catégories pour configurer les règles de mots-clés
|
||||
6. Utilisez l'auto-catégorisation sur la page Transactions pour appliquer vos règles en masse
|
||||
7. Configurez votre Budget et suivez votre progression via les Rapports
|
||||
Les huit étapes ci-dessous sont celles du parcours de démarrage. La carte d'accueil du tableau de bord d'un profil neuf les liste, puis le bandeau en haut de chaque page mène de l'une à la suivante (voir « Le parcours de démarrage », plus bas) ; chaque étape se coche d'elle-même quand elle est faite.
|
||||
|
||||
1. Créer un profil — chaque profil a sa propre base de données
|
||||
2. Choisir un dossier d'import — le dossier où vous déposerez vos relevés, un sous-dossier par compte bancaire
|
||||
3. Configurer une source d'import — la première ouverture d'un sous-dossier détecte le format du fichier
|
||||
4. Importer un premier fichier — après avoir lu l'aperçu et son récapitulatif signé
|
||||
5. Parcourir les catégories et les mots-clés — ce que font les mots-clés, avant de classer
|
||||
6. Classer une transaction à la main — le seed de mots-clés fait le gros du travail, une correction manuelle affine le reste
|
||||
7. Saisir un premier budget — édition Base et plus ; suivez ensuite votre progression via les Rapports
|
||||
8. Parcourir les rapports — la période, les faits saillants et les cartes de rapports
|
||||
|
||||
### Le parcours de démarrage
|
||||
|
||||
1. Le bandeau de démarrage s'affiche en haut de chaque page tant que le parcours n'est pas masqué. Il indique votre progression, par exemple « Démarrage 3/8 », et l'étape suivante, avec un lien vers la page où la faire. Il n'apparaît pas sur le tableau de bord d'un profil sans transaction, dont la carte d'accueil contient déjà la liste
|
||||
2. « Voir la liste » déplie les huit étapes sous le bandeau et « Fermer la liste » les replie ; la liste se replie aussi quand vous changez de page. Chaque étape à faire mène à la page où la faire
|
||||
3. Sur les pages Import, Catégories, Transactions, Budget et Rapports, quand l'étape suivante s'y fait, une carte de démarrage remplace le bandeau. Elle indique « Étape n sur N », dit quoi faire et entoure d'un contour le contrôle à utiliser. Le contour pulse trois fois, et pas du tout si votre système demande de réduire les animations. Si vous arrivez par un lien « Étape suivante », le focus du clavier se place sur le titre de la carte. Sur les pages Catégories et Rapports, la carte se termine par « J'ai compris », qui coche l'étape ; sur la page Rapports, ouvrir la carte Tendances entourée la coche aussi
|
||||
4. La carte suit l'étape sans que vous changiez de page. Sur la page Import, elle accompagne l'assistant écran par écran. Le lien de l'étape « Classer une transaction à la main » ouvre la page Transactions sur les transactions non catégorisées, toutes dates confondues, ou sur toutes les transactions s'il n'en reste aucune à classer. Sur la page Budget, la carte désigne d'abord « Tout déplier », puis la cellule du mois courant de la première catégorie à remplir ; elle ne déplie jamais la grille à votre place. Sur la page Transactions, la carte propose le clic droit et « Ajouter comme mot-clé » ; sur la page Budget, elle rappelle que seules les catégories du dernier niveau acceptent un montant et ce que fait la colonne Annuel
|
||||
5. Dès que l'étape est faite, la carte reste affichée, annonce « Fait. » et propose l'étape suivante, avec un lien vers sa page. Sur la page Import, elle passe d'une étape à la suivante sans quitter la page et annonce « Fait. » une fois le fichier importé ; jamais pour un import qui n'a ajouté aucune transaction : le rapport dit pourquoi, et l'étape reste à faire
|
||||
6. « Passer cette étape », sur la carte, marque l'étape comme passée : elle compte dans la progression, et la liste l'affiche avec le badge « Passée » et un lien « Reprendre ». « Reprendre » remet l'étape à faire et ouvre sa page
|
||||
7. Quand toutes les étapes sont faites ou passées, le bandeau, ou la carte de la dernière étape, annonce « Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite. » « Ouvrir le guide » mène à cette section Premiers pas, et « Fermer » masque le parcours
|
||||
8. « Masquer », dans le bandeau ou dans la liste, masque le bandeau et la liste pour ce profil seulement. Pour les retrouver, ouvrez Paramètres → Utilisateurs et cliquez sur « Afficher la liste de démarrage » : le parcours réapparaît en haut des pages. Ce bouton est visible dès que le parcours est masqué
|
||||
9. Un profil qui contenait déjà des transactions avant l'arrivée du parcours guidé le voit masqué d'office à sa première ouverture, comme après « Masquer » ; Paramètres → Utilisateurs le réaffiche. Un profil vide ou neuf le garde
|
||||
|
||||
Les étapes se cochent d'elles-mêmes, sur vos données et sans changer de page : « Importer un premier fichier » attend un fichier qui a ajouté au moins une transaction, « Classer une transaction à la main » attend une transaction dont vous avez choisi la catégorie vous-même, et la catégorisation automatique de l'import ne la coche pas. Les deux étapes de visite, « Parcourir les catégories et les mots-clés » et « Parcourir les rapports », se cochent par « J'ai compris » sur leur carte ; un profil qui avait terminé le parcours avant leur arrivée les trouve déjà faites. En édition Gratuite, l'étape Budget est verrouillée et ne compte pas : le parcours se termine à 7/7.
|
||||
|
||||
### Astuces
|
||||
|
||||
|
|
@ -60,6 +78,9 @@ Gérez plusieurs profils indépendants, chacun avec sa propre base de données.
|
|||
- Un profil par défaut est créé automatiquement au premier lancement
|
||||
- Le NIP est demandé à chaque fois que vous accédez à un profil protégé
|
||||
- La suppression d'un profil supprime définitivement toutes ses données — cette action est irréversible
|
||||
- À l'ouverture d'un profil créé par une version antérieure, l'application met sa base à jour après en avoir gardé une copie dans le dossier `migration-backups`, à côté des bases (sous Linux `~/.config/com.simpl.resultat/`, sous Windows `%APPDATA%\com.simpl.resultat\`). Une seule copie est gardée par profil, la plus récente : elle est refaite à chaque mise à jour de la base et supprimée avec le profil
|
||||
- Sous Linux, les versions jusqu'à la 0.17.0 laissaient sur le disque la base d'un profil supprimé (`~/.config/com.simpl.resultat/profile_….db`) ; vous pouvez supprimer ce fichier à la main
|
||||
- Le parcours de démarrage est propre à chaque profil : ses étapes se cochent sur les données du profil, et le masquer dans un profil ne le masque pas dans les autres
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -75,15 +96,19 @@ Le tableau de bord vous donne un aperçu rapide de votre situation financière p
|
|||
- Histogramme empilé des dépenses par catégorie et par mois
|
||||
- Sélecteur de période ajustable (par défaut : année à ce jour)
|
||||
- Menu contextuel (clic droit) pour masquer une catégorie ou voir ses transactions
|
||||
- Carte d'accueil d'un profil sans transaction, avec la liste de démarrage : huit étapes qui se cochent d'elles-mêmes selon vos données, et un bouton « Masquer »
|
||||
|
||||
### Comment faire
|
||||
|
||||
1. Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)
|
||||
2. Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales
|
||||
3. Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie
|
||||
4. Consultez le tableau Budget vs Réel pour comparer vos dépenses au budget du mois courant
|
||||
5. Analysez l'histogramme en bas de page pour voir l'évolution de vos dépenses par catégorie dans le temps
|
||||
6. Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions
|
||||
1. Dans un profil sans transaction, le tableau de bord affiche une carte d'accueil et la liste de démarrage : créer un profil (déjà cochée), choisir un dossier d'import, configurer une source d'import, importer un premier fichier, classer une transaction à la main, saisir un premier budget. Chaque ligne non cochée mène à la page où la faire ; la case Budget est marquée « Base et plus » en édition Gratuite et ne compte pas dans le total
|
||||
2. Dès que le profil a des transactions, la liste quitte le tableau de bord : le bandeau de démarrage, en haut de chaque page, la remplace jusqu'à la fin du parcours (voir Premiers pas → Le parcours de démarrage)
|
||||
3. « Masquer » retire la liste de la carte d'accueil, et le bandeau de toutes les pages, pour ce profil seulement ; Paramètres → Utilisateurs les réaffiche
|
||||
4. Utilisez le sélecteur de période en haut à droite pour choisir une plage de temps (mois, 3 mois, année, etc.)
|
||||
5. Consultez les cartes résumées pour votre solde, revenus totaux et dépenses totales
|
||||
6. Vérifiez le graphique circulaire pour voir comment vos dépenses sont réparties par catégorie
|
||||
7. Consultez le tableau Budget vs Réel pour comparer vos dépenses au budget du mois courant
|
||||
8. Analysez l'histogramme en bas de page pour voir l'évolution de vos dépenses par catégorie dans le temps
|
||||
9. Cliquez droit sur une catégorie dans un graphique pour la masquer ou voir le détail de ses transactions
|
||||
|
||||
### Astuces
|
||||
|
||||
|
|
@ -91,6 +116,7 @@ Le tableau de bord vous donne un aperçu rapide de votre situation financière p
|
|||
- Le solde est calculé comme les revenus moins les dépenses pour la période sélectionnée
|
||||
- Les catégories masquées apparaissent sous forme de pastilles au-dessus du graphique — cliquez sur Tout afficher pour les restaurer
|
||||
- Les motifs SVG (lignes, points, hachures) aident à distinguer les catégories au-delà des couleurs
|
||||
- Les cases de la liste de démarrage se cochent sur vos données, pas sur un clic : « Classer une transaction à la main » attend une transaction dont vous avez changé la catégorie vous-même, la catégorisation automatique de l'import ne la coche pas
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -113,14 +139,80 @@ Importez des relevés bancaires à partir de fichiers CSV à l'aide d'un assista
|
|||
### Comment faire
|
||||
|
||||
1. Définissez votre dossier d'import via le sélecteur de dossier en haut de la page
|
||||
2. Créez un sous-dossier pour chaque banque/source et placez-y les fichiers CSV
|
||||
2. Créez un sous-dossier par compte et placez-y ses fichiers CSV : la carte de crédit et le compte chèques d'une même banque font deux sources, et les fichiers posés directement dans le dossier parent sont ignorés — tant que le dossier est vide, le bouton « Créer un dossier d'exemple » dépose deux sous-dossiers fictifs (« Exemple Desjardins », « Exemple Tangerine ») pour essayer l'assistant sans attendre vos vrais relevés ; supprimez-les ensuite
|
||||
3. Cliquez sur une source pour ouvrir l'assistant d'import — la première fois, le format est détecté automatiquement et un bandeau annonce le résultat
|
||||
4. Vérifiez la configuration proposée (délimiteur, encodage, format de date, mapping des colonnes) et corrigez-la si le bandeau signale un format incertain
|
||||
5. Sélectionnez les fichiers à importer
|
||||
6. **Examinez l'aperçu** : le récapitulatif indique combien de sorties et d'entrées le fichier produira, et pour quels montants
|
||||
7. Si les entrées et les sorties sont inversées, cliquez sur « Inverser les signes » — la correction est enregistrée avec la source, les prochains fichiers de cette banque se liront correctement d'eux-mêmes
|
||||
6. **Examinez l'aperçu** : le récapitulatif indique combien de sorties et d'entrées le fichier produira, et pour quels montants ; une dépense doit compter parmi les sorties, en négatif
|
||||
7. Si les entrées et les sorties sont inversées, cliquez sur « Inverser les signes » — la correction est enregistrée avec la source, les prochains fichiers de ce compte se liront correctement d'eux-mêmes
|
||||
8. Vérifiez les doublons, examinez le résumé, puis confirmez l'import
|
||||
|
||||
### Exporter un CSV depuis votre banque
|
||||
|
||||
1. Créez un sous-dossier par compte dans votre dossier d'import (un pour le compte chèque, un pour la carte de crédit, et ainsi de suite)
|
||||
2. Sur le site de votre banque, choisissez la plage de dates la plus large offerte
|
||||
3. Gardez le fichier tel quel : ne l'ouvrez pas et ne le réenregistrez pas dans un tableur, qui modifierait les dates et les montants
|
||||
4. Relancez l'export chaque mois et déposez le nouveau fichier dans le même sous-dossier
|
||||
|
||||
Les banques ci-dessous ont une procédure détaillée. La ligne de statut de chacune indique si elle a été confirmée sur un compte réel.
|
||||
|
||||
#### Desjardins
|
||||
|
||||
*Procédure reconstituée depuis des guides tiers, à confirmer sur un compte réel*
|
||||
|
||||
1. Connectez-vous à AccèsD sur le site de Desjardins (pas l'application mobile)
|
||||
2. Ouvrez Comptes et choisissez le folio voulu
|
||||
3. Ouvrez l'onglet Relevés (ou Transactions / Historique selon la version)
|
||||
4. Choisissez la période
|
||||
5. Cliquez sur Télécharger ou Exporter, puis choisissez le format CSV
|
||||
|
||||
L'export ne couvre que l'activité récente du folio. Pour une carte de crédit Desjardins, le CSV n'est pas garanti : certaines cartes n'offrent que le PDF.
|
||||
|
||||
#### Tangerine
|
||||
|
||||
*Étapes confirmées par la FAQ officielle de Tangerine*
|
||||
|
||||
1. Connectez-vous sur tangerine.ca (pas l'application mobile)
|
||||
2. Ouvrez Transactions
|
||||
3. Cliquez sur le bouton « Télécharger les transactions » (« Download Transactions » en anglais)
|
||||
4. Choisissez le compte et la plage de dates
|
||||
5. Choisissez le format CSV, puis cliquez sur Télécharger
|
||||
|
||||
L'historique disponible peut être limité à quelques mois : relancez l'export régulièrement.
|
||||
|
||||
#### RBC
|
||||
|
||||
*Procédure reconstituée depuis des guides tiers, à confirmer sur un compte réel*
|
||||
|
||||
1. Connectez-vous à Banque en direct RBC
|
||||
2. Ouvrez Mes comptes et choisissez le compte
|
||||
3. Cliquez sur le bouton Télécharger, au-dessus du tableau des opérations
|
||||
4. Choisissez le format CSV, puis cliquez sur Continuer
|
||||
5. Enregistrez le fichier dans le sous-dossier du compte
|
||||
|
||||
Les cartes de crédit n'offrent qu'environ trois mois d'historique.
|
||||
|
||||
#### Banque Nationale (BNC)
|
||||
|
||||
*Aucune procédure publique trouvée pour les particuliers*
|
||||
|
||||
1. L'application attend un fichier dont l'en-tête est `Date;Description;Catégorie;Débit;Crédit;Solde`
|
||||
2. Si votre export BNC a une autre forme, envoyez-nous **uniquement la ligne d'en-tête et une ou deux lignes aux valeurs fictives** via Paramètres → Système → Envoyer un feedback
|
||||
|
||||
**Ne collez jamais un numéro de compte, un solde ni une transaction réelle** : la rétroaction est un message texte envoyé à un serveur, c'est la seule exception au fonctionnement local de l'application.
|
||||
|
||||
#### BMO
|
||||
|
||||
*Procédure reconstituée d'après une description générale, à confirmer sur un compte réel ; format de fichier non encore reconnu*
|
||||
|
||||
1. Connectez-vous aux Services bancaires en ligne BMO sur bmo.com (le site, pas l'application)
|
||||
2. Dans le sommaire, cliquez sur le compte voulu (chèques, épargne ou Mastercard)
|
||||
3. Au-dessus de la liste des transactions, cliquez sur « Télécharger » (ou « Exporter »)
|
||||
4. Choisissez le format CSV — les autres options visent QuickBooks, Quicken, Simply Accounting ou Microsoft Money
|
||||
5. Choisissez la période, puis téléchargez le fichier
|
||||
|
||||
L'historique téléchargeable est limité à **123 jours** au maximum, et à environ **trois mois plus les transactions non facturées** pour une Mastercard : exportez chaque mois. Les prêts, hypothèques et marges de crédit ne s'exportent pas (relevés PDF seulement, conservés 7 ans). Les colonnes diffèrent entre les comptes de dépôt et la carte de crédit. BMO n'a pas encore de format reconnu par l'application : la détection générique lit la plupart des exports à montants signés ; si l'aperçu signale des entrées et des sorties inversées, utilisez « Inverser les signes ». Si le fichier n'est pas lu, envoyez-nous **uniquement la ligne d'en-tête et une ou deux lignes aux valeurs fictives** via Paramètres → Système → Envoyer un feedback. **Ne collez jamais un numéro de compte, un solde ni une transaction réelle** : la rétroaction est un message texte envoyé à un serveur, c'est la seule exception au fonctionnement local de l'application.
|
||||
|
||||
### Astuces
|
||||
|
||||
- Le récapitulatif de l'aperçu est le seul contrôle qui voit le **sens** des montants : un score de 100 % signifie que chaque ligne a été lue, pas que vos dépenses ne sont pas comptées comme des revenus. Prenez deux secondes pour le lire.
|
||||
|
|
@ -132,6 +224,8 @@ Importez des relevés bancaires à partir de fichiers CSV à l'aide d'un assista
|
|||
- Les fichiers déjà importés sont marqués d'un badge — les ré-importer déclenchera la détection de doublons
|
||||
- Vous pouvez supprimer un import de l'historique pour retirer toutes ses transactions
|
||||
- Vos sources et vos modèles d'import font désormais partie de la sauvegarde chiffrée : restaurer un backup ne vous oblige plus à tout reconfigurer
|
||||
- Si des fichiers CSV sont posés directement dans le dossier d'import, un avertissement les nomme : déplacez-les dans un sous-dossier par compte
|
||||
- Le nom d'une source est celui de son sous-dossier
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -147,7 +241,7 @@ Parcourez, filtrez, triez et catégorisez toutes vos transactions importées. C'
|
|||
- Assignation de catégorie en ligne via menu déroulant
|
||||
- Auto-catégorisation basée sur les règles de mots-clés
|
||||
- Ajout de mots-clés directement depuis une transaction
|
||||
- Répartition (split) d'une transaction sur plusieurs catégories
|
||||
- Répartition (split) d'une transaction sur plusieurs catégories (édition Base et plus)
|
||||
- Notes sur les transactions
|
||||
|
||||
### Comment faire
|
||||
|
|
@ -156,15 +250,15 @@ Parcourez, filtrez, triez et catégorisez toutes vos transactions importées. C'
|
|||
2. Cliquez sur un en-tête de colonne pour trier par ordre croissant ou décroissant
|
||||
3. Pour catégoriser une transaction, cliquez sur son menu déroulant de catégorie et sélectionnez une catégorie
|
||||
4. Pour auto-catégoriser toutes les transactions non catégorisées, cliquez sur le bouton Auto-catégoriser
|
||||
5. Pour ajouter une règle de mot-clé depuis une transaction, cliquez sur l'icône + et entrez le mot-clé
|
||||
6. Pour répartir une transaction sur plusieurs catégories, utilisez le bouton Répartition et ajoutez les montants par catégorie
|
||||
5. Pour ajouter une règle de mot-clé depuis une transaction, faites un clic droit sur sa ligne, puis « Ajouter comme mot-clé » ; sur une transaction déjà classée, l'icône étiquette ajoute aussi un mot-clé à sa catégorie
|
||||
6. Pour répartir une transaction sur plusieurs catégories, utilisez le bouton Répartition et ajoutez les montants par catégorie (édition Base et plus ; en édition Gratuite, le bouton porte un cadenas et ouvre l'écran de déverrouillage)
|
||||
|
||||
### Astuces
|
||||
|
||||
- Utilisez les boutons de période rapide (Ce mois, Mois dernier, etc.) pour filtrer rapidement par date
|
||||
- L'auto-catégorisation n'affecte que les transactions non catégorisées — elle n'écrase pas les assignations manuelles
|
||||
- Ajouter un mot-clé depuis une transaction pré-remplit la catégorie pour construire rapidement vos règles
|
||||
- Les transactions réparties affichent un indicateur visuel et le détail de la répartition
|
||||
- Les transactions réparties affichent un indicateur visuel, quelle que soit l'édition ; le détail de la répartition s'ouvre par le bouton Répartition (édition Base et plus)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -185,7 +279,7 @@ Gérez votre arbre de catégories avec des sous-catégories, des règles de mots
|
|||
|
||||
### Comment faire
|
||||
|
||||
1. Cliquez sur Ajouter une catégorie pour créer une nouvelle catégorie — choisissez un nom, un type et un parent optionnel
|
||||
1. Cliquez sur Ajouter une catégorie pour créer une nouvelle catégorie — choisissez un nom, un type et un parent optionnel (tapez quelques lettres pour filtrer la liste des parents)
|
||||
2. Sélectionnez une catégorie dans l'arbre pour voir ses détails et sa liste de mots-clés
|
||||
3. Glissez-déposez une catégorie dans l'arbre pour la réordonner ou la déplacer sous un autre parent
|
||||
4. Ajoutez des mots-clés correspondant aux descriptions des transactions pour l'auto-catégorisation
|
||||
|
|
@ -246,14 +340,14 @@ Planifiez votre budget mensuel pour chaque catégorie et suivez le prévu par ra
|
|||
1. Utilisez le navigateur d'année pour sélectionner l'année du budget
|
||||
2. Cliquez sur une cellule de mois pour entrer un montant prévu
|
||||
3. Appuyez sur Entrée pour sauvegarder, Échap pour annuler, ou Tab pour passer au mois suivant
|
||||
4. Utilisez le bouton de répartition (sur la colonne Annuel) pour distribuer également sur tous les mois
|
||||
4. Saisissez un montant dans la colonne Annuel pour le répartir également sur les 12 mois ; il remplace les montants mensuels de la ligne
|
||||
5. Sauvegardez votre budget comme modèle pour le réutiliser les années suivantes
|
||||
|
||||
### Astuces
|
||||
|
||||
- La colonne Annuel additionne automatiquement les 12 mois — un avertissement apparaît si les totaux mensuels ne correspondent pas
|
||||
- La colonne Annuel additionne automatiquement les 12 mois
|
||||
- Les modèles peuvent être appliqués à des mois spécifiques ou aux 12 mois d'un coup
|
||||
- Les catégories parentes affichent les sous-totaux agrégés de leurs enfants
|
||||
- Seules les catégories du dernier niveau acceptent un montant ; les catégories parentes affichent les sous-totaux de leurs enfants
|
||||
- Replier une catégorie est **purement visuel** : les sous-totaux et les totaux annuels sont toujours calculés sur toutes les lignes, jamais seulement sur les lignes visibles — aucun montant saisi n'est perdu quand vous repliez
|
||||
|
||||
---
|
||||
|
|
@ -264,7 +358,7 @@ Planifiez votre budget mensuel pour chaque catégorie et suivez le prévu par ra
|
|||
|
||||
### Le hub
|
||||
|
||||
En haut, un **panneau de faits saillants** condensé : solde net du mois courant + solde cumul annuel (YTD) avec sparkline 12 mois, top mouvements vs mois précédent et top 5 des plus grosses transactions récentes. En bas, quatre cartes mènent aux quatre sous-rapports dédiés.
|
||||
En haut, un **panneau de faits saillants** condensé : solde net du mois courant + solde cumul annuel (YTD) avec sparkline 12 mois, top mouvements vs mois précédent et top 5 des plus grosses transactions récentes. En bas, cinq cartes mènent aux cinq sous-rapports dédiés.
|
||||
|
||||
Le sélecteur de période en haut à droite est **partagé** entre toutes les pages via l'URL : `?from=YYYY-MM-DD&to=YYYY-MM-DD`. Copiez l'URL pour revenir plus tard au même état ou la partager.
|
||||
|
||||
|
|
@ -347,7 +441,7 @@ Trois modes accessibles via un tab bar :
|
|||
|
||||
Les trois modes ci-dessus, comme le tableau **Par catégorie** des Tendances, présentent les catégories en arbre repliable **à chaque niveau**. Chaque catégorie parente — y compris les niveaux intermédiaires — porte son propre chevron : le déplier révèle ses enfants directs (eux-mêmes repliés), de sorte que vous descendez d'un niveau à la fois. Le tableau s'ouvre **entièrement replié** (seules les catégories de premier niveau visibles) ; le bouton **« Tout déplier / Tout replier »** ouvre ou ferme tous les niveaux d'un coup. Replier une catégorie est **purement visuel** — les sous-totaux, totaux et lignes de résultat restent calculés sur l'ensemble des lignes, jamais sur les seules lignes visibles. Vos choix de repli sont **mémorisés par profil** (dans la base du profil, ils disparaissent donc avec lui).
|
||||
|
||||
### Rapport Zoom catégorie (`/reports/category`)
|
||||
### Rapport Analyse par catégorie (`/reports/category`)
|
||||
|
||||
Choisissez une catégorie dans la combobox en haut. Par défaut le rapport inclut automatiquement les sous-catégories (toggle *Directe seulement* pour les exclure). Vous voyez :
|
||||
|
||||
|
|
@ -357,10 +451,10 @@ Choisissez une catégorie dans la combobox en haut. Par défaut le rapport inclu
|
|||
|
||||
### Édition contextuelle des mots-clés
|
||||
|
||||
**Clic droit** sur n'importe quelle transaction (dans le zoom catégorie, la liste des faits saillants, ou la page Transactions) ouvre un menu *Ajouter comme mot-clé*. Un dialog affiche :
|
||||
**Clic droit** sur n'importe quelle transaction (dans l'analyse par catégorie, la liste des faits saillants, ou la page Transactions) ouvre un menu *Ajouter comme mot-clé*. Un dialog affiche :
|
||||
|
||||
1. Une **prévisualisation** des transactions qui seront recatégorisées (jusqu'à 50 visibles avec cases à cocher individuelles — les matches au-delà de 50 peuvent être appliqués via une case explicite)
|
||||
2. Un sélecteur de catégorie cible
|
||||
2. Un sélecteur de catégorie cible, où taper quelques lettres filtre la liste
|
||||
3. Un bouton **Appliquer et recatégoriser**
|
||||
|
||||
L'application est atomique : soit toutes les transactions cochées sont recatégorisées et le mot-clé enregistré, soit rien n'est fait. Si le mot-clé existait déjà pour une autre catégorie, un prompt vous demande si vous voulez le réassigner — cela ne touche **pas** l'historique, seulement les transactions visibles cochées.
|
||||
|
|
@ -369,7 +463,7 @@ L'application est atomique : soit toutes les transactions cochées sont recatég
|
|||
|
||||
- Le toggle **graphique / tableau** est mémorisé par sous-rapport (vos préférences restent même après redémarrage)
|
||||
- Les mots-clés doivent faire entre 2 et 64 caractères (protection contre les regex explosives)
|
||||
- Le zoom catégorie est **protégé contre les arborescences cycliques** : un éventuel `parent_id` malformé ne fait pas planter l'app
|
||||
- L'analyse par catégorie est **protégée contre les arborescences cycliques** : un éventuel `parent_id` malformé ne fait pas planter l'app
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -493,6 +587,7 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
|
|||
- Export des données (transactions, catégories, ou les deux) en format JSON ou CSV
|
||||
- Import des données depuis un fichier exporté précédemment
|
||||
- Chiffrement AES-256-GCM optionnel pour les fichiers exportés
|
||||
- Réaffichage du parcours de démarrage (Paramètres → Utilisateurs) quand il a été masqué
|
||||
|
||||
### Comment faire
|
||||
|
||||
|
|
@ -503,6 +598,7 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
|
|||
5. Utilisez la section Gestion des données pour exporter ou importer vos données
|
||||
6. Lors de l'export, choisissez ce qu'il faut inclure et définissez optionnellement un mot de passe pour le chiffrement
|
||||
7. Lors de l'import, sélectionnez un fichier exporté précédemment — les fichiers chiffrés demanderont le mot de passe
|
||||
8. Pour revoir le parcours de démarrage après l'avoir masqué, ouvrez Paramètres → Utilisateurs et cliquez sur « Afficher la liste de démarrage » : le parcours réapparaît en haut des pages de ce profil, et un message le confirme. Le bouton est visible dès que le parcours est masqué, même depuis le bandeau de cette page
|
||||
|
||||
### Astuces
|
||||
|
||||
|
|
@ -511,7 +607,7 @@ Configurez les préférences de l'application, vérifiez les mises à jour, acc
|
|||
- Exportez régulièrement pour garder une sauvegarde de vos données
|
||||
- Le guide d'utilisation peut être imprimé ou exporté en PDF via le bouton Imprimer
|
||||
- Les journaux persistent pendant la session — ils survivent à un rafraîchissement de la page
|
||||
- Le feedback est la seule fonctionnalité qui communique avec un serveur en dehors des mises à jour et de la connexion Maximus — chaque envoi est explicite, aucune télémétrie automatique
|
||||
- L'application ne contacte un serveur que pour les mises à jour, la connexion Maximus, le feedback, la licence (activation et vérification) et les cours du marché. Une seule requête part d'elle-même, au démarrage et à la saisie d'une clé : la vérification de la licence, au plus une fois par 24 heures pour une même clé et seulement si une clé est enregistrée. Aucune télémétrie
|
||||
- En cas de problème, cliquez Envoyer un feedback et cochez « Inclure les derniers logs d'erreur » pour joindre les journaux récents automatiquement
|
||||
|
||||
---
|
||||
|
|
@ -526,13 +622,14 @@ Simpl'Résultat existe en trois éditions : **Gratuite**, **Base** et **Premium*
|
|||
|---|:---:|:---:|:---:|
|
||||
| Tableau de bord | ✓ | ✓ | ✓ |
|
||||
| Import CSV | ✓ | ✓ | ✓ |
|
||||
| Transactions | ✓ | ✓ | ✓ |
|
||||
| Transactions (sans la Répartition) | ✓ | ✓ | ✓ |
|
||||
| Catégories | ✓ | ✓ | ✓ |
|
||||
| Rapports — hub et Tendances | ✓ | ✓ | ✓ |
|
||||
| Export / import chiffré | ✓ | ✓ | ✓ |
|
||||
| Journal des modifications | ✓ | ✓ | ✓ |
|
||||
| Profils | 1 profil | ✓ multiples | ✓ multiples |
|
||||
| Ajustements | — | ✓ | ✓ |
|
||||
| Répartition des transactions | — | ✓ | ✓ |
|
||||
| Budget | — | ✓ | ✓ |
|
||||
| Rapports avancés (Faits saillants, Comparables, Analyse par catégorie, Cartes) | — | ✓ | ✓ |
|
||||
| Mises à jour automatiques | — | ✓ | ✓ |
|
||||
|
|
@ -540,20 +637,36 @@ Simpl'Résultat existe en trois éditions : **Gratuite**, **Base** et **Premium*
|
|||
|
||||
### Comment ça se présente
|
||||
|
||||
Les modules au-dessus de votre édition restent **visibles mais verrouillés** : un cadenas apparaît dans la barre latérale (Budget, Ajustements, Bilan) et sur les tuiles de rapports avancés du hub Rapports. Les ouvrir affiche un écran de déverrouillage qui indique l'édition requise, avec deux actions :
|
||||
Les modules au-dessus de votre édition restent **visibles mais verrouillés** : un cadenas apparaît dans la barre latérale (Budget, Ajustements, Bilan), sur les tuiles de rapports avancés du hub Rapports et sur le bouton Répartition de la page Transactions. Les ouvrir affiche un écran de déverrouillage qui indique l'édition requise, avec deux actions :
|
||||
|
||||
- **Obtenir Base / Premium** — l'achat en ligne arrive bientôt ; le bouton est affiché mais désactivé en attendant
|
||||
- **Obtenir Base** — ouvre la page produit dans votre navigateur pour acheter l'édition Base. Sur l'écran du Bilan, réservé à Premium, le bouton s'intitule « Obtenir Premium » et reste désactivé : Premium n'est pas encore offert en ligne
|
||||
- **J'ai déjà une clé** — mène directement à la carte de licence (Paramètres → Utilisateurs) pour entrer votre clé
|
||||
|
||||
### Comment faire
|
||||
|
||||
1. Repérez le cadenas dans la barre latérale ou sur les tuiles du hub Rapports : il marque les modules au-dessus de votre édition
|
||||
1. Repérez le cadenas dans la barre latérale, sur les tuiles du hub Rapports ou sur le bouton Répartition de la page Transactions : il marque ce qui est au-dessus de votre édition
|
||||
2. Cliquez sur un module verrouillé pour voir l'édition requise
|
||||
3. Si vous avez une clé de licence, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la
|
||||
4. Les modules se déverrouillent immédiatement — aucune réinstallation ni redémarrage nécessaire
|
||||
3. Pour acheter l'édition Base, cliquez sur « Obtenir Base » : la page produit s'ouvre dans votre navigateur (Premium n'est pas encore offert en ligne)
|
||||
4. Une fois votre clé de licence en main, cliquez sur « J'ai déjà une clé » (ou allez dans Paramètres → Utilisateurs) et entrez-la
|
||||
5. Les modules se déverrouillent immédiatement — aucune réinstallation ni redémarrage nécessaire
|
||||
|
||||
### La carte Licence
|
||||
|
||||
La carte Licence (Paramètres → Utilisateurs) réunit votre clé et vos machines :
|
||||
|
||||
- **Édition et échéance** — l'édition de votre clé et sa date d'échéance, dans la langue de l'application. Une clé valide pour plus de 50 ans affiche « Sans échéance »
|
||||
- **Entrer une clé** — si la clé est refusée, le message en donne la raison : elle ne commence pas par `SR-BASE-` ou `SR-PREMIUM-`, elle est illisible ou n'a pas pu être vérifiée (le plus souvent parce qu'elle a été copiée en partie ou coupée par un retour à la ligne ; une clé complète qui reste refusée n'a pas été émise par le serveur de licences), elle est expirée, ou elle désigne une édition que votre version de l'application ne connaît pas
|
||||
- **Machines** — activer une machine la lie à votre clé auprès du serveur de licences. L'activation ne sert qu'aux cours du marché du Bilan (Premium) : votre édition n'en dépend jamais. Après une réinstallation, un changement d'ordinateur, l'enregistrement d'une autre clé ou l'expiration de l'activation, la carte affiche « Non activée » ; cliquez sur « Activer » pour retrouver les cours. L'application retire aussi d'elle-même une activation qui ne sert plus, et la carte affiche alors « Non activée » : quand la récupération d'un cours est refusée parce que l'activation de cette machine appartient à une licence révoquée (par exemple celle d'une clé remboursée, restée à côté de votre clé actuelle depuis une version antérieure), et quand la liste des machines ne compte pas cette machine (désactivée depuis un autre ordinateur, par exemple). Chacun de ces retraits est inscrit dans les journaux (Paramètres → Système)
|
||||
- **Ce que l'activation envoie** — votre clé de licence, le nom de cet ordinateur et un identifiant dérivé, propre à Simpl'Résultat, à la place de l'identifiant machine du système. Le nom sert à reconnaître vos machines dans la liste. Une machine activée avec une version antérieure garde l'identifiant de cette activation jusqu'à ce que vous la désactiviez puis l'activiez de nouveau
|
||||
- **Nombre de machines** — la liste des machines indique combien sont activées sur le nombre permis par votre clé, par exemple « 2/3 machines activées ». Désactivez une machine pour libérer une place
|
||||
- **Clé révoquée** — si le serveur de licences signale que votre clé a été révoquée, par exemple après un remboursement, l'application repasse en édition Gratuite et le reste d'une session à l'autre : la carte Licence, les écrans verrouillés et la carte Mises à jour l'indiquent. Vos données sont conservées, et une autre clé valide redonne son édition
|
||||
- **Vérification de la licence** — quand une clé est enregistrée, l'application vérifie auprès du serveur de licences qu'elle n'a pas été révoquée : au démarrage et dès que vous entrez une clé, au plus une fois par 24 heures pour une même clé, en envoyant le produit et la clé, rien d'autre. La vérification d'une clé entrée se fait en arrière-plan : la carte Licence rend la main tout de suite, et une clé révoquée affiche son édition une à deux secondes avant de repasser en Gratuite. Sans clé enregistrée, aucune requête ne part ; si le serveur ne répond pas, rien ne change. Chaque vérification est inscrite dans les journaux (Paramètres → Système). La vérification continue après une révocation, tant que la clé reste enregistrée : une correction faite par le serveur de licences est ainsi reprise au démarrage suivant. Pour arrêter cette vérification, retirez la clé (« Retirer la clé », ci-dessous) ; l'enregistrement d'une autre clé arrête aussi l'envoi de la précédente
|
||||
- **Retirer la clé** — « Retirer la clé » retire votre clé de cet ordinateur, après une confirmation qui dit ce que cela change. Une clé valide : les modules de votre édition se verrouillent et l'application repasse en édition Gratuite ; vos données sont conservées et réapparaissent dès qu'une clé valide est entrée. Une clé révoquée : l'application cesse de l'envoyer au serveur de licences. Si cette machine est activée, sa place est d'abord libérée sur le serveur de licences ; si le serveur ne répond pas (hors ligne, par exemple), l'application demande s'il faut retirer la clé quand même, et la place reste alors occupée jusqu'à ce que vous la libériez depuis la liste des machines, sur un autre ordinateur où la clé est enregistrée. Chaque retrait est inscrit dans les journaux. Une clé révoquée retirée puis entrée de nouveau reste révoquée
|
||||
|
||||
### Astuces
|
||||
|
||||
- Le verrouillage n'est **jamais destructif** : si votre édition baisse (clé expirée, changement de machine), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée
|
||||
- Le verrouillage n'est **jamais destructif** : si votre édition baisse (clé expirée, invalide, révoquée ou retirée), les données des modules verrouillés — budgets, ajustements, snapshots de bilan, profils — sont intégralement conservées et réapparaissent dès qu'une clé valide est entrée
|
||||
- En édition Gratuite, votre **profil actif reste toujours accessible** — seuls la création d'un profil supplémentaire et le passage à un autre profil sont verrouillés
|
||||
- En édition Gratuite, les **répartitions déjà faites restent dans vos données** : elles sont toujours comptées dans les rapports et le tableau de bord, et toujours signalées dans la liste des transactions — seuls l'ouverture et la modification d'une répartition sont verrouillées
|
||||
- La clé de licence s'applique à toute la machine, pas à un profil : elle déverrouille les modules pour tous les profils du poste
|
||||
- En édition Gratuite, les mises à jour automatiques sont verrouillées : la carte Mises à jour (Paramètres → Système) propose de télécharger la dernière version depuis la page produit, et l'installation manuelle reste gratuite
|
||||
|
|
|
|||
550
docs/qa-onboarding.html
Normal file
550
docs/qa-onboarding.html
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>QA — Parcours d'accueil</title>
|
||||
<!--
|
||||
Checklist de parcours de l'accueil : phase 1 (issues #351 à #357) et parcours
|
||||
guidé de la phase 2 (issues #396 à #401). Page statique, français seulement,
|
||||
aucune dépendance réseau : tout est inline. L'état (cases + commentaires) vit
|
||||
dans le localStorage du navigateur qui ouvre le fichier, sous une clé propre à
|
||||
cette version de la liste ; « Copier le rapport » produit le texte brut à
|
||||
coller dans Paramètres → Système → Envoyer un feedback.
|
||||
La liste de la phase 1 telle que publiée avec la v0.17.0 reste lisible par
|
||||
`git show v0.17.0:docs/qa-onboarding.html`.
|
||||
-->
|
||||
<style>
|
||||
:root {
|
||||
--bg: #faf9f7;
|
||||
--card: #ffffff;
|
||||
--fg: #1f1d1a;
|
||||
--muted: #6b665f;
|
||||
--border: #e3dfd9;
|
||||
--accent: #b5542e;
|
||||
--accent-fg: #ffffff;
|
||||
--done-bg: #f0f5ec;
|
||||
--focus: #2f6fb3;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #1c1a18;
|
||||
--card: #262320;
|
||||
--fg: #ece8e2;
|
||||
--muted: #a39d94;
|
||||
--border: #3a3631;
|
||||
--accent: #d9774c;
|
||||
--accent-fg: #1c1a18;
|
||||
--done-bg: #262d22;
|
||||
--focus: #7fb0e8;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
padding: 24px 16px 64px;
|
||||
}
|
||||
main { max-width: 860px; margin: 0 auto; }
|
||||
h1 { font-size: 1.5rem; margin: 0 0 4px; }
|
||||
h2 { font-size: 1.05rem; margin: 28px 0 8px; }
|
||||
p { margin: 0 0 10px; }
|
||||
.lead { color: var(--muted); }
|
||||
.meta {
|
||||
display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center;
|
||||
margin: 16px 0 8px; color: var(--muted); font-size: 0.9rem;
|
||||
}
|
||||
.meta label { display: inline-flex; gap: 6px; align-items: center; }
|
||||
.meta input[type="text"] {
|
||||
width: 11rem;
|
||||
}
|
||||
.progress {
|
||||
font-variant-numeric: tabular-nums; font-weight: 600; color: var(--fg);
|
||||
}
|
||||
.prereq {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 12px 16px; margin: 12px 0 20px;
|
||||
}
|
||||
.prereq ul { margin: 6px 0 0; padding-left: 20px; }
|
||||
.intro { color: var(--muted); font-size: 0.92rem; }
|
||||
ol.steps { list-style: none; margin: 0; padding: 0; counter-reset: step; }
|
||||
ol.steps li {
|
||||
background: var(--card); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 12px 14px; margin-bottom: 10px;
|
||||
display: grid; grid-template-columns: auto 1fr; gap: 6px 12px;
|
||||
}
|
||||
ol.steps li.done { background: var(--done-bg); }
|
||||
ol.steps li .check { grid-row: 1 / span 3; padding-top: 2px; }
|
||||
ol.steps li input[type="checkbox"] { width: 20px; height: 20px; margin: 0; cursor: pointer; }
|
||||
ol.steps li .title { font-weight: 600; cursor: pointer; }
|
||||
ol.steps li .title::before {
|
||||
counter-increment: step; content: counter(step) ". ";
|
||||
color: var(--muted); font-weight: 500;
|
||||
}
|
||||
ol.steps li .expect { color: var(--muted); font-size: 0.92rem; }
|
||||
ol.steps li .expect code {
|
||||
font: 0.88em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 0 4px;
|
||||
}
|
||||
ol.steps li .where { display: grid; gap: 4px; }
|
||||
ol.steps li .where label { font-size: 0.85rem; color: var(--muted); }
|
||||
input[type="text"], textarea {
|
||||
width: 100%; font: inherit; color: var(--fg); background: var(--bg);
|
||||
border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px;
|
||||
}
|
||||
input:focus-visible, textarea:focus-visible, button:focus-visible {
|
||||
outline: 2px solid var(--focus); outline-offset: 1px;
|
||||
}
|
||||
.actions {
|
||||
display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
|
||||
margin-top: 20px; position: sticky; bottom: 0; padding: 12px 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
button {
|
||||
font: inherit; font-weight: 600; cursor: pointer; border-radius: 8px;
|
||||
padding: 8px 14px; border: 1px solid var(--border);
|
||||
background: var(--card); color: var(--fg);
|
||||
}
|
||||
button.primary { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||||
button:hover { filter: brightness(0.97); }
|
||||
.status { color: var(--muted); font-size: 0.9rem; min-height: 1.4em; }
|
||||
.fallback { margin-top: 12px; }
|
||||
.fallback[hidden] { display: none; }
|
||||
.fallback textarea { min-height: 14rem; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.85rem; }
|
||||
.note { font-size: 0.85rem; color: var(--muted); margin-top: 16px; }
|
||||
@media (max-width: 520px) {
|
||||
body { padding: 16px 16px 72px; }
|
||||
.meta input[type="text"] { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>QA — Parcours d'accueil</h1>
|
||||
<p class="lead">Première ouverture de Simpl'Résultat jusqu'à la fin du parcours de démarrage guidé. Coche chaque étape quand elle se passe comme décrit, et note où tu as hésité, même une seconde : c'est l'hésitation qu'on cherche, pas l'échec.</p>
|
||||
|
||||
<div class="meta">
|
||||
<label>Testeur <input type="text" id="tester" placeholder="prénom" autocomplete="off"></label>
|
||||
<label>Version <input type="text" id="version" placeholder="ex. 0.18.0" autocomplete="off"></label>
|
||||
<label>Système <input type="text" id="os" placeholder="Windows 11, Ubuntu 24.04…" autocomplete="off"></label>
|
||||
<span class="progress" id="progress"></span>
|
||||
</div>
|
||||
|
||||
<section class="prereq">
|
||||
<strong>Avant de commencer</strong>
|
||||
<ul>
|
||||
<li>Une version publiée ou un build de release : les cas du parcours guidé vérifient le rendu réel du webview, WebKitGTK sous Linux et WebView2 sous Windows.</li>
|
||||
<li>Une installation neuve, ou un profil neuf : la liste de démarrage n'apparaît que dans un profil sans transaction.</li>
|
||||
<li>Pour l'étape Budget, une clé Base ou Premium ; sans clé, l'étape se présente verrouillée « Base et plus » et le total passe à 5 — note-le dans le champ, c'est attendu.</li>
|
||||
<li>Pour le cas de l'édition Gratuite, une session utilisateur ou une machine sans clé : ne retire pas la clé de ta machine habituelle, puisque retirer une clé efface aussi le jeton d'activation.</li>
|
||||
<li>Pour la règle D9, un profil qui a des transactions, créé avec la v0.17.0 ou avant, et qu'aucune version du parcours guidé n'a encore ouvert (un build de développement de la pile compte).</li>
|
||||
<li>Les cas d'accessibilité et de rendu se font sous Linux avec Orca, puis sous Windows avec NVDA.</li>
|
||||
<li>La page garde tes réponses dans ce navigateur. Rien n'est envoyé : c'est toi qui colles le rapport dans l'application.</li>
|
||||
<li>Pour vérifier la v0.17.0 elle-même, prends la liste publiée avec elle : <code>git show v0.17.0:docs/qa-onboarding.html</code>.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div id="sections"></div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="primary" id="copy">Copier le rapport</button>
|
||||
<button type="button" id="reset">Réinitialiser</button>
|
||||
<span class="status" id="status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
|
||||
<div class="fallback" id="fallback" hidden>
|
||||
<p>Le presse-papiers n'est pas accessible depuis ce fichier : sélectionne le texte ci-dessous (il est déjà sélectionné) et copie-le avec Ctrl+C, puis colle-le dans Paramètres → Système → Envoyer un feedback.</p>
|
||||
<textarea id="report" readonly aria-label="Rapport à copier"></textarea>
|
||||
</div>
|
||||
|
||||
<p class="note">Les noms entre guillemets sont ceux des boutons tels qu'ils apparaissent en français. « Exemple Desjardins » et « Exemple Tangerine » sont des relevés inventés : aucune donnée réelle.</p>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// v2: the guided path (#396 to #401) changed the list; the answers of the
|
||||
// phase 1 list stay under their own key.
|
||||
var STORAGE_KEY = "simpl-resultat.qa-onboarding.v2";
|
||||
|
||||
// One group per part of the path, one row per case. `expect` is what the
|
||||
// tester should see. Ids are unique across the groups.
|
||||
var SECTIONS = [
|
||||
{
|
||||
id: "phase-1",
|
||||
title: "Phase 1 : premier lancement, états vides et dossier d'exemple",
|
||||
intro: "Les cas de la phase 1 (#351 à #357) qui restent valides avec le parcours guidé. Ils peuvent se faire dans la même passe que la section suivante, sur le même profil neuf.",
|
||||
steps: [
|
||||
{ id: "install", title: "Installer et lancer l'application",
|
||||
expect: "L'installateur s'exécute sans avertissement bloquant et l'application s'ouvre sur la sélection de profil." },
|
||||
{ id: "profile", title: "Créer un profil",
|
||||
expect: "Le profil s'ouvre directement après sa création (NIP facultatif)." },
|
||||
{ id: "dashboard-empty", title: "Tableau de bord vide avec la liste de démarrage à 1 / 8",
|
||||
expect: "Une carte d'accueil remplace les graphiques, la liste montre huit lignes, seule « Créer un profil » est cochée, le compteur indique 1 / 8 (ou 1 / 7 sans clé)." },
|
||||
{ id: "cta-folder", title: "Cliquer « Choisir un dossier d'import »",
|
||||
expect: "Le bouton de la carte d'accueil mène à la page Import." },
|
||||
{ id: "import-no-folder", title: "Page Import sans dossier",
|
||||
expect: "Un état vide « Aucun dossier d'import » avec le bouton « Choisir un dossier » et le lien « Comment exporter un CSV depuis ma banque ? »." },
|
||||
{ id: "pick-folder", title: "Cliquer « Choisir un dossier » et prendre un dossier vide",
|
||||
expect: "La fenêtre du système s'ouvre ; une fois le dossier choisi, l'état vide devient « Aucune source trouvée » et nomme le dossier." },
|
||||
{ id: "create-sample", title: "Cliquer « Créer un dossier d'exemple »",
|
||||
expect: "Un message annonce les deux dossiers créés et invite à cliquer sur l'un d'eux. Dans l'explorateur de fichiers, les deux sous-dossiers contiennent un fichier releve-AAAA-MM.csv." },
|
||||
{ id: "two-sources", title: "Deux sources apparaissent",
|
||||
expect: "« Exemple Desjardins » et « Exemple Tangerine » sont listées comme sources, sans actualiser à la main." },
|
||||
{ id: "import-desjardins", title: "Importer « Exemple Desjardins » : aperçu, signes, doublons, rapport",
|
||||
expect: "Le format est détecté (bandeau Desjardins), l'aperçu montre des sorties et des entrées dans le bon sens (pas besoin d'« Inverser les signes »), aucun doublon, puis le rapport d'import indique le nombre de transactions importées." },
|
||||
{ id: "manual-category", title: "Transactions : classer « GARDERIE LES PETITS PAS » à la main",
|
||||
expect: "La transaction est sans catégorie après l'import ; lui en donner une à la main la garde classée." },
|
||||
{ id: "budget", title: "Budget (clé Base ou Premium) : saisir un montant",
|
||||
expect: "La page Budget montre la grille (pas l'état vide, puisqu'il y a des transactions) ; un montant saisi dans une cellule est conservé." },
|
||||
{ id: "hide-reshow", title: "« Masquer » sur un second profil, puis réaffichage depuis Paramètres",
|
||||
expect: "Créer un second profil (clé Base ou plus) : sa carte d'accueil montre la liste à 1 / 8. « Masquer » retire la liste de ce profil seulement, ainsi que le bandeau de ses autres pages ; le premier profil n'est pas touché. Paramètres → Utilisateurs montre « Afficher la liste de démarrage », qui ramène le parcours." },
|
||||
{ id: "guide-link", title: "Ouvrir le guide « Exporter un CSV depuis votre banque » depuis l'état vide d'Import",
|
||||
expect: "Sur un profil sans dossier ou sans source, le lien de l'état vide ouvre le guide directement sur la sous-section, avec les procédures par banque." }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "parcours-guide",
|
||||
title: "Parcours guidé : un profil neuf de bout en bout",
|
||||
intro: "Phase 2 (#396 à #401), sur un profil neuf en édition Base ou Premium. De l'étape 2 à l'étape 8, avance seulement par les liens d'étape (ligne de la liste, « Étape suivante »), sans revenir au tableau de bord : chaque page doit dire quoi faire.",
|
||||
steps: [
|
||||
{ id: "guided-dashboard", title: "Tableau de bord vide sans bandeau, autres pages avec bandeau",
|
||||
expect: "Sur le tableau de bord, aucun bandeau de démarrage au-dessus du titre : la carte d'accueil porte déjà la liste. Ouvre Catégories, puis Rapports : chacune commence par le bandeau « Démarrage 1/8 · Étape suivante : Choisir un dossier d'import → », avec « Voir la liste » et « Masquer »." },
|
||||
{ id: "guided-import-card", title: "Étape 2 : la carte Import, son contour et le focus",
|
||||
expect: "Suis le lien « Choisir un dossier d'import » du bandeau ou de la liste. La page Import s'ouvre sur la carte « Étape 2 sur 8 · Choisir un dossier d'import », avant le titre et à la place du bandeau, et le focus du clavier est sur le titre de la carte. La phrase nomme le bouton « Choisir un dossier », entouré d'un contour de couleur d'accent qui pulse trois fois, puis reste." },
|
||||
{ id: "guided-import-folder", title: "Étape 3 : dossier choisi, puis dossier d'exemple",
|
||||
expect: "Choisis un dossier vide : sans quitter la page, la carte passe à « Étape 3 sur 8 · Configurer une source d'import », nomme le dossier et entoure « Créer un dossier d'exemple ». Après la création, elle demande de cliquer sur la carte d'un compte et entoure la première source." },
|
||||
{ id: "guided-import-wizard", title: "Étape 4 : l'assistant, écran par écran",
|
||||
expect: "Clique la source entourée (« Exemple Desjardins »). À chaque écran, la carte dit quoi vérifier et entoure le bouton suivant : « Aperçu », « Vérifier les doublons », « Confirmer », puis « Importer ». Pendant l'import, elle affiche « Import en cours… ». Sur le rapport, elle annonce « Fait. » et propose « Étape suivante : Parcourir les catégories et les mots-clés → » ; après « Terminé », elle garde « Fait. »." },
|
||||
{ id: "guided-categories", title: "Étape 5 : la carte Catégories, « Tous les mots-clés » et « J'ai compris » (#432)",
|
||||
expect: "Clique « Étape suivante » sur la carte Import. La page Catégories s'ouvre sur la carte « Étape 5 sur 8 · Parcourir les catégories et les mots-clés », avant le titre, et le focus est sur le titre de la carte. Elle dit : « Un mot-clé classe d'office, dès l'import, chaque transaction dont la description le contient ; quand deux mots-clés s'appliquent, le plus prioritaire l'emporte. « Tous les mots-clés » les liste tous, avec leur catégorie. À l'étape suivante, vous en ajouterez un depuis une transaction. » Le contour entoure « Tous les mots-clés », jamais « Réinitialiser ». Ouvrir « Tous les mots-clés » ne coche rien. Clique « J'ai compris » : la carte reste, annonce « Fait. » et propose « Étape suivante : Classer une transaction à la main → », et le contour disparaît. En anglais, “All Keywords” est entre “ ”." },
|
||||
{ id: "guided-transactions", title: "Étape 6 : Transactions, entrée guidée",
|
||||
expect: "Clique « Étape suivante » sur la carte Catégories, après « J'ai compris » (étape 5, #432). La page Transactions s'ouvre filtrée sur « Non catégorisées », période « Tout », avec la carte « Étape 6 sur 8 · Classer une transaction à la main » et le focus sur son titre ; le contour entoure la catégorie de la première ligne. Choisis une catégorie pour l'une d'elles (« GARDERIE LES PETITS PAS », par exemple) : sans quitter la page, la carte annonce « Fait. » et propose « Étape suivante : Saisir un premier budget → », et le contour disparaît." },
|
||||
{ id: "guided-budget", title: "Étape 7 : Budget, « Tout déplier » puis la cellule du mois",
|
||||
expect: "Clique « Étape suivante » sur la carte Transactions. La page Budget montre la carte « Étape 7 sur 8 · Saisir un premier budget », la grille repliée et le contour sur « Tout déplier ». Clique-le : la phrase change et le contour passe sur la cellule du mois courant de la première catégorie à remplir. Saisis un montant puis Entrée : la carte annonce « Fait. » et propose « Étape suivante : Parcourir les rapports → »." },
|
||||
{ id: "guided-budget-tab", title: "Budget : Tab enregistre et passe au mois suivant",
|
||||
expect: "Dans une cellule, saisis un montant puis Tab : le montant est enregistré et la saisie passe au mois suivant, comme le dit la carte." },
|
||||
{ id: "guided-reports", title: "Étape 8 : la carte Rapports, la carte Tendances et « J'ai compris » (#432)",
|
||||
expect: "Clique « Étape suivante » sur la carte Budget. La page Rapports s'ouvre sur la carte « Étape 8 sur 8 · Parcourir les rapports », avant le titre, et le focus est sur le titre de la carte. Elle dit : « Choisissez la période en haut de la page : le panneau des faits saillants résume votre solde et vos plus gros mouvements. Chaque carte du bas ouvre un rapport détaillé ; commencez par « Tendances ». » Le contour entoure la carte Tendances, seule. Changer la période ne coche rien. Clique « J'ai compris » : la carte reste, annonce « Fait. » et le message de fin « Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite. », et le contour disparaît. En anglais, “Trends” est entre “ ”." },
|
||||
{ id: "guided-live", title: "Les étapes se cochent page ouverte",
|
||||
expect: "Pendant les étapes 2 à 8, chaque étape s'est cochée sur la page même, sans recharger ni changer de page, en moins d'une seconde : passage de l'étape 2 à l'étape 3 au choix du dossier, puis « Fait. » après l'import, chaque « J'ai compris », le classement et le budget. Note tout délai visible, ou une étape qui ne se coche qu'au changement de page." },
|
||||
{ id: "guided-end", title: "Fin du parcours : bandeau de fin, « Ouvrir le guide » et « Fermer »",
|
||||
expect: "Après « J'ai compris » sur la carte Rapports (étape 8, #432), quitte la page : en haut de chaque page, tableau de bord compris, le bandeau dit « Démarrage terminé. Le guide d'utilisation vous accompagne pour la suite. ». « Ouvrir le guide » ouvre Paramètres → Utilisateurs sur la section « Premiers pas », qui décrit le parcours (« Le parcours de démarrage »). « Fermer » retire le bandeau de toutes les pages, et Paramètres → Utilisateurs propose alors « Afficher la liste de démarrage »." }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "parcours-guide-liste",
|
||||
title: "Parcours guidé : bandeau, liste, Passer et Reprendre",
|
||||
intro: "Sur un profil où le parcours n'est pas terminé, par exemple un second profil neuf (clé Base ou plus).",
|
||||
steps: [
|
||||
{ id: "guided-list", title: "« Voir la liste » et « Fermer la liste »",
|
||||
expect: "Sur une page sans carte (Paramètres, par exemple), « Voir la liste » déplie les huit étapes sous le bandeau et devient « Fermer la liste », qui les replie. Change de page : la liste est de nouveau repliée. Une étape faite est barrée et sans lien ; une étape à faire mène à sa page. À juger : liste dépliée, le compteur et « Masquer » apparaissent deux fois, dans le bandeau et dans l'en-tête de la liste." },
|
||||
{ id: "guided-skip-resume", title: "« Passer cette étape », puis « Reprendre »",
|
||||
expect: "Sur la carte Transactions ou Budget, clique « Passer cette étape » : la carte laisse la place au bandeau (ou au message de fin si c'était la dernière étape) et le compteur avance d'une étape. Sur la page Import, passer une étape fait passer la carte à l'étape suivante de la page. Dans la liste dépliée, l'étape passée montre l'icône « passer », le badge « Passée » et « Reprendre ». Depuis une autre page, « Reprendre » rouvre la page de l'étape avec sa carte, le focus sur son titre, et l'étape redevient à faire." },
|
||||
{ id: "guided-hide-users", title: "« Masquer » depuis Paramètres → Utilisateurs",
|
||||
expect: "Sur Paramètres → Utilisateurs, clique « Masquer » dans le bandeau : le bandeau disparaît et, sans quitter la page, la ligne « Parcours de démarrage » apparaît avec « Afficher la liste de démarrage ». Un clic la remplace par « Le parcours de démarrage réapparaît en haut des pages », et le bandeau revient en haut de la page." }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "parcours-guide-cas",
|
||||
title: "Parcours guidé : cas particuliers et limites connues",
|
||||
intro: "Chaque cas dit sur quel profil le faire. Les limites connues sont à constater et à juger : note si elles gênent.",
|
||||
steps: [
|
||||
{ id: "guided-d9", title: "Profil déjà actif masqué d'office, puis réaffiché (règle D9)",
|
||||
expect: "Ouvre un profil qui avait des transactions avant cette version. Aucun bandeau ni carte, sur aucune page ; le Journal (Paramètres → Système) porte « Onboarding: the profile already had transactions, getting-started path hidden (Settings → Users shows it again) ». Paramètres → Utilisateurs propose « Afficher la liste de démarrage » : un clic ramène le bandeau, avec un compteur fidèle aux données du profil. Rouvrir le profil ne le masque plus." },
|
||||
{ id: "guided-free", title: "Édition Gratuite : 7 étapes, fin à 7/7",
|
||||
expect: "Sur une session ou une machine sans clé, avec un profil neuf : le compteur dit 1/7, et la ligne Budget est verrouillée « Base et plus » et mène à la carte Licence. Après le classement d'une transaction, la carte Transactions (« Étape 6 sur 7 ») annonce « Fait. » puis « Étape suivante : Parcourir les rapports → » : aucune carte Budget ; après « J'ai compris » sur la carte Rapports, fin à 7/7." },
|
||||
{ id: "guided-noop-import", title: "Un import qui n'ajoute rien",
|
||||
expect: "Sur un profil de test où « Importer un premier fichier » reste à faire, fais un import qui n'ajoute aucune transaction (par exemple un fichier qui n'a que sa ligne d'en-tête, ou un format de date choisi à tort, qui met toutes les lignes en erreur dans l'aperçu). Sur le rapport, la carte dit « L'import n'a ajouté aucune transaction ; le rapport ci-dessous dit pourquoi. » et n'annonce pas « Fait. ». Après « Terminé », la carte reste sur « Étape 4 sur 8 » (« 4 sur 7 » en édition Gratuite), entoure la première source et n'annonce toujours pas « Fait. » ; la liste laisse « Importer un premier fichier » à faire." },
|
||||
{ id: "guided-skip-folder", title: "Limite connue : « Passer » sur l'étape du dossier, sans dossier",
|
||||
expect: "Sur un profil neuf, sur la carte Import « Étape 2 sur 8 · Choisir un dossier d'import », clique « Passer cette étape » sans choisir de dossier. La carte passe à « Étape 3 sur 8 · Configurer une source d'import », mais garde la phrase et le contour du choix du dossier ; un second « Passer » mène à l'étape 4, avec la même phrase, et le bandeau revient au troisième. À juger : les étapes de la page dépendent du dossier." },
|
||||
{ id: "guided-empty-profile", title: "Étapes d'import passées : Transactions et Budget sans transaction",
|
||||
expect: "Sur un profil neuf en édition Base ou plus, sur un build de production (le mode dev masque le cas Budget), choisis un dossier d'import vide, puis passe les étapes 3, 4 et 5 avec « Passer cette étape » (l'étape 5 sur la carte Catégories, #432). Sur la page Transactions, la carte propose le clic droit et « Ajouter comme mot-clé » au-dessus de l'état vide « Aucune transaction pour l'instant », que la barre de filtres et un bref chargement peuvent précéder (limite connue, à juger). Passe l'étape 6 depuis cette carte. Sur la page Budget, la carte « Étape 7 sur 8 · Saisir un premier budget » garde son titre et « Passer cette étape », sans phrase ni contour, au-dessus de l'état vide « Le budget se construit sur vos transactions » ; elle ne dit jamais « Cliquez « Tout déplier »… »." },
|
||||
{ id: "guided-fallback", title: "Transactions : aucune transaction à classer",
|
||||
expect: "Sur un profil de test où l'étape 6 reste à faire, rends toutes les transactions catégorisées sans les classer à la main : ajoute, depuis la page Catégories, un mot-clé pour chaque transaction restée sans catégorie, puis clique « Auto-catégoriser » sur la page Transactions. Depuis une autre page, suis « Étape suivante : Classer une transaction à la main → ». La page montre toutes les transactions, période « Tout », une seule fois, et la carte dit « Faites un clic droit sur une transaction, puis « Ajouter comme mot-clé »… ». « Aucune transaction trouvée. » peut s'afficher un très bref instant entre les deux lectures (limite connue)." },
|
||||
{ id: "guided-same-page", title: "Limite connue : un lien d'étape suivi depuis la page Transactions",
|
||||
expect: "Passe l'étape 6 depuis sa carte, change les filtres de la page Transactions, puis, sur cette même page, clique « Reprendre » dans la liste du bandeau. La carte revient, mais la page garde tes filtres au lieu de l'entrée guidée ; la phrase de la carte suit le filtre réellement affiché. À juger." },
|
||||
{ id: "guided-budget-expanded", title: "Budget : grille déjà dépliée par le profil",
|
||||
expect: "Sur un profil où l'étape 7 reste à faire, déplie la grille (« Tout déplier ») sans saisir de montant, puis quitte la page Budget. Reviens par le lien de l'étape 7 : la grille est restée dépliée et la carte entoure directement la cellule du mois courant." },
|
||||
{ id: "guided-budget-offscreen", title: "Limite connue : cellule du mois hors de la zone visible",
|
||||
expect: "En fenêtre étroite, la grille du budget défile horizontalement et la cellule du mois courant peut être hors de vue (les derniers mois de l'année sont à droite) : rien ne défile tout seul. À juger : la phrase et le contour suffisent-ils pour la trouver ?" },
|
||||
{ id: "guided-budget-zero", title: "Budget : une cellule quittée vide enregistre 0 $",
|
||||
expect: "Sur un profil où l'étape 7 reste à faire, clique une cellule puis quitte-la sans rien saisir : 0 $ est enregistré et la carte annonce « Fait. ». C'est le comportement existant de la grille ; à juger." },
|
||||
{ id: "guided-import-flash", title: "Limite connue : « Aucun dossier configuré » à l'ouverture d'Import",
|
||||
expect: "À l'ouverture de la page Import, la carte du dossier peut afficher un très bref instant « Aucun dossier configuré » avant la lecture du dossier ; l'état vide et la carte de démarrage, eux, attendent cette lecture. À juger." }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "retours-v0171",
|
||||
title: "Retours de la v0.17.1 : textes et cartes du parcours",
|
||||
intro: "Jalon planned-2026-10-12-retours-onboarding-v0171. Sur un profil neuf, en français, puis en anglais pour relire les mêmes phrases.",
|
||||
steps: [
|
||||
{ id: "v0171-import-structure", title: "Import : l'encadré « Structure attendue » (#422)",
|
||||
expect: "Sans dossier choisi (« Aucun dossier d'import »), puis dans un dossier vide (« Aucune source trouvée »), l'état vide montre l'encadré « Structure attendue » au-dessus du lien « Comment exporter un CSV depuis ma banque ? » : « Dossier parent : choisissez celui-ci », dessous « Carte de crédit : une source » avec releve-2026-08.csv et releve-2026-09.csv, puis « Compte chèques : une autre source », et la note « Les fichiers posés directement dans le dossier parent sont ignorés. ». Les niveaux se lisent en retrait, dans les deux thèmes ; un lecteur d'écran lit une liste imbriquée, sans annoncer les icônes." },
|
||||
{ id: "v0171-import-card-folder", title: "Import : cartes « Choisir un dossier d'import » et « Configurer une source d'import » (#422)",
|
||||
expect: "Sans dossier, la carte dit : « Choisissez avec le bouton « Choisir un dossier » le dossier parent de vos relevés. Simpl'Résultat y lit un sous-dossier par compte : un pour la carte de crédit, un pour le compte chèques, et ainsi de suite. » Dans un dossier vide, elle nomme le dossier entre guillemets, demande un sous-dossier par compte et cite « Actualiser » et « Créer un dossier d'exemple » entre guillemets (« deux comptes fictifs »). Une fois les sources présentes : « Cliquez sur la carte d'un compte… ». Nulle part la carte ne parle d'un sous-dossier par banque." },
|
||||
{ id: "v0171-import-card-preview", title: "Import : la carte de l'aperçu parle des signes (#422)",
|
||||
expect: "À l'écran d'aperçu de l'assistant, la carte dit qu'une dépense doit compter parmi les sorties, en négatif, cite « Inverser les signes » comme correction, puis « Vérifier les doublons », chacun entre guillemets ; le contour reste sur « Vérifier les doublons ». Aux écrans suivants, « Confirmer » puis « Importer » sont aussi entre guillemets. En anglais, les mêmes libellés sont entre “ ”." },
|
||||
{ id: "v0171-import-help", title: "Import : l'aide de la page et le guide (#422)",
|
||||
expect: "L'aide de la page Import (icône ?) commence par « Choisissez le dossier parent de vos relevés… » et porte, après « Prévisualisez vos données… », la règle des signes avec « Inverser les signes ». Dans le guide d'utilisation (section Import), l'étape 2 dit « Créez un sous-dossier par compte… », l'étape 6 qu'une dépense doit compter parmi les sorties, et l'étape 7 parle des prochains fichiers « de ce compte »." },
|
||||
{ id: "v0171-import-root-files-with-source", title: "Import : fichier posé à la racine, à côté d'une source (#427)",
|
||||
expect: "Dans le dossier d'import, pose a.csv directement à la racine et crée un sous-dossier visa avec un relevé, puis « Actualiser » : la carte de la source visa s'affiche, et au-dessus d'elle un avertissement (icône triangle) dit « 1 fichier est posé directement dans « <nom du dossier> » et est ignoré : a.csv. Déplacez-le dans un sous-dossier par compte. ». Le dossier est nommé par son dernier segment, jamais par son chemin." },
|
||||
{ id: "v0171-import-root-files-no-source", title: "Import : trois fichiers à la racine, aucune source (#427)",
|
||||
expect: "Dans un dossier sans sous-dossier mais avec trois CSV à la racine, « Actualiser » : l'état vide « Aucune source trouvée » s'affiche, et sous lui l'avertissement au pluriel nomme les trois fichiers, séparés par des virgules. Avec sept fichiers, il en nomme cinq puis « et 2 autres ». En anglais : “… sit directly in “<folder>” and are ignored: … and 2 more.”" },
|
||||
{ id: "v0171-import-root-files-lifecycle", title: "Import : l'avertissement suit le dossier (#427)",
|
||||
expect: "Déplace les fichiers de la racine dans un sous-dossier puis « Actualiser » : l'avertissement disparaît. Remets-en un, « Actualiser » : il revient. Ouvre une source puis « Annuler » (ou va au bout d'un import puis « Terminé ») : l'avertissement est toujours là. Dans Paramètres → Systèmes → Journal, aucune ligne ne contient le nom d'un de ces fichiers ni le chemin du dossier." },
|
||||
{ id: "v0171-import-source-name", title: "Import : le nom de la source est celui de son sous-dossier (#426)",
|
||||
expect: "Ouvre une source : la configuration affiche sous « Nom de la source » le nom du sous-dossier, en texte, sans champ de saisie, et dessous « Le nom de la source est celui de son sous-dossier. ». La carte de démarrage dit « Vérifiez les fichiers cochés, puis cliquez « Aperçu ». » et ne parle plus du nom. En anglais : “A source is named after its subfolder.” et “Check the selected files, then click “Preview”.”" },
|
||||
{ id: "v0171-import-source-reopen", title: "Import : la source est retrouvée au prochain import (#426)",
|
||||
expect: "Importe un fichier d'une source, puis « Terminé » et rouvre la même source : aucun bandeau de détection (le format enregistré est restauré tel quel), le fichier déjà importé porte le badge « Importé » et n'est pas coché. Sur une base créée avant ce correctif, une source déjà renommée garde ce défaut (hors périmètre)." },
|
||||
{ id: "v0171-transactions-card-keyword", title: "Transactions : la carte propose « Ajouter comme mot-clé » (#430)",
|
||||
expect: "Par le lien de l'étape 6, la carte dit : « Seules les transactions non catégorisées sont affichées. Faites un clic droit sur l'une d'elles, puis « Ajouter comme mot-clé » : le mot-clé classe cette transaction et les transactions semblables de l'aperçu, et servira aux prochains imports. Gardez un mot propre au commerçant, pas « PAIEMENT », et décochez les transactions déjà bien classées. Vous pouvez aussi cliquer sur sa catégorie et en choisir une. » Avec le filtre « Toutes », la phrase commence par « Faites un clic droit sur une transaction… » et garde la même consigne. Le contour reste sur la catégorie de la première ligne. En anglais, “Add as keyword” et “PAYMENT” sont entre “ ”." },
|
||||
{ id: "v0171-transactions-keyword-checks", title: "Transactions : la boîte du mot-clé coche l'étape (#430)",
|
||||
expect: "Sur un profil où l'étape 6 reste à faire, suis la carte : clic droit sur une transaction, « Ajouter comme mot-clé », remplace le mot prérempli par un mot propre au commerçant si besoin, choisis une catégorie, décoche une transaction de l'aperçu, puis « Appliquer et recatégoriser ». La carte annonce « Fait. » sans quitter la page ; la transaction décochée garde sa catégorie. La boîte elle-même n'a pas changé." },
|
||||
{ id: "v0171-budget-card-leaves", title: "Budget : la carte nomme les feuilles et la colonne « Annuel » (#430)",
|
||||
expect: "Grille repliée, la carte dit : « Cliquez « Tout déplier » pour afficher vos catégories. Seules celles du dernier niveau, sans chevron, acceptent un montant : cliquez sur un mois et saisissez-le (Entrée enregistre ; Tab enregistre et passe au mois suivant). Un montant saisi dans la colonne « Annuel » est réparti également sur les 12 mois et remplace les montants mensuels. » Après « Tout déplier », la phrase commence par « Seules les catégories du dernier niveau… » et garde la phrase sur « Annuel ». Vérifie dans la grille que l'affirmation tient : une ligne à chevron n'accepte pas de montant ; 120 $ dans « Annuel » donne 10 $ par mois et remplace les montants déjà saisis. En anglais, “Expand all” et “Annual” sont entre “ ”." },
|
||||
{ id: "v0171-visit-eight-rows", title: "Liste de démarrage : huit lignes, deux étapes de visite (#425)",
|
||||
expect: "Sur un profil neuf, la liste montre dans l'ordre : « Créer un profil », « Choisir un dossier d'import », « Configurer une source d'import », « Importer un premier fichier », « Parcourir les catégories et les mots-clés », « Classer une transaction à la main », « Saisir un premier budget », « Parcourir les rapports ». Le compteur dit 1 / 8 (1 / 7 sans clé, la ligne Budget verrouillée « Base et plus »). Après l'import, « Étape suivante » mène à « Parcourir les catégories et les mots-clés ». Ouvrir Catégories ou Rapports sans cliquer ne coche rien. En anglais : “Browse categories and keywords”, “Browse the reports”." },
|
||||
{ id: "v0171-visit-got-it", title: "« J'ai compris » coche une étape de visite (#425, #432)",
|
||||
expect: "Sur la carte d'une étape de visite (pages Catégories et Rapports), « J'ai compris » s'affiche avant « Passer cette étape ». Un clic coche l'étape : la carte reste, annonce « Fait. » et propose l'étape suivante (ou le message de fin après Rapports) ; dans la liste, la ligne est barrée, sans le badge « Passée ». « Passer cette étape » donne, lui, une ligne « Passée » avec « Reprendre ». En anglais : “Got it”." },
|
||||
{ id: "v0171-reports-free-padlock", title: "Rapports : la carte mentionne le cadenas en édition Gratuite (#432)",
|
||||
expect: "Sur une session ou une machine sans clé, arrive sur la carte Rapports par « Étape suivante » (« Étape 7 sur 7 »). La phrase finit par « Les cartes à cadenas demandent l'édition Base. », et les cartes Faits saillants, Comparables, Zoom catégorie et Cartes portent un cadenas ; la carte Tendances, entourée, n'en a pas. Avec une clé Base ou plus, la phrase s'arrête à « commencez par « Tendances ». ». En anglais : “Cards with a padlock require the Base edition.”" },
|
||||
{ id: "v0171-reports-trends-click", title: "Rapports : ouvrir la carte Tendances entourée coche l'étape (#432)",
|
||||
expect: "Sur un profil où l'étape 8 est la dernière à faire, arrive sur la carte Rapports par « Étape suivante », puis clique la carte Tendances entourée (pas « J'ai compris »). Le rapport Tendances s'ouvre, et le bandeau, en haut de cette page, annonce « Démarrage terminé. » ; dans la liste, « Parcourir les rapports » est barrée, sans le badge « Passée ». Sur un profil où une étape antérieure reste à faire, ouvre Rapports par le menu : le bandeau s'affiche, sans carte d'étape ni contour, et cliquer Tendances ne coche pas « Parcourir les rapports »." },
|
||||
{ id: "v0171-visit-auto-done", title: "Profil au parcours fini : les étapes de visite sont faites d'office (#425)",
|
||||
expect: "Ouvre, avec cette version, un profil dont les six anciennes étapes étaient faites ou passées et dont le parcours n'est pas masqué (il affichait « Démarrage terminé »). À aucun moment le bandeau ne passe par « Étape suivante : Parcourir les catégories et les mots-clés » : il garde « Démarrage terminé ». Dans la liste, les deux étapes de visite sont barrées. Le Journal (Paramètres → Système) porte « Onboarding: the path was already complete, visit steps marked done ». Rouvrir le profil ne change plus rien." },
|
||||
{ id: "v0171-visit-incomplete", title: "Profil au parcours incomplet : les étapes de visite restent à faire (#425)",
|
||||
expect: "Ouvre, avec cette version, un profil dont au moins une des six anciennes étapes restait à faire. Les deux étapes de visite sont à faire, et le restent une fois les six autres terminées : le compteur dit 6/8, et « Étape suivante » mène à la première étape de visite non faite." },
|
||||
{ id: "v0171-visit-pending", title: "Limite connue : lignes de visite « en attente » avant l'initialisation (#425)",
|
||||
expect: "À la toute première ouverture d'un profil avec cette version, le temps de lire la licence et d'initialiser les étapes de visite (un instant), la liste peut montrer les deux étapes de visite « en attente » (cercle pâle, lu « en attente » par un lecteur d'écran), jamais avec le badge « Base et plus », et sans compteur. Elles passent ensuite à faire ou faites. À juger si l'instant est visible." }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "parcours-guide-a11y",
|
||||
title: "Parcours guidé : accessibilité et rendu",
|
||||
intro: "Sous Linux (WebKitGTK, lecteur d'écran Orca), puis sous Windows (WebView2, lecteur d'écran NVDA). Indique le système dans le champ prévu.",
|
||||
steps: [
|
||||
{ id: "guided-render-linux", title: "Contour et pulsation sous Linux (WebKitGTK)",
|
||||
expect: "Un contour de couleur d'accent, d'environ 3 px et décalé de 2 px, entoure le contrôle désigné sur les trois pages ; la pulsation joue trois fois en un peu moins de 4 secondes, puis le contour reste fixe. Note s'il est rogné par la grille ou par un conteneur." },
|
||||
{ id: "guided-render-windows", title: "Contour et pulsation sous Windows (WebView2)",
|
||||
expect: "Même attendu que sous Linux : contour de couleur d'accent d'environ 3 px, décalé de 2 px, trois pulsations, puis un contour fixe, jamais rogné." },
|
||||
{ id: "guided-reduced-motion", title: "Mouvement réduit (prefers-reduced-motion)",
|
||||
expect: "Active l'option du système qui réduit les animations (GNOME : réduire les animations, dans Accessibilité ; Windows : effets d'animation désactivés, dans Accessibilité → Effets visuels), puis relance l'application si rien ne change : le contour s'affiche, sans aucune pulsation." },
|
||||
{ id: "guided-contrast", title: "Contraste du contour, thème clair et thème sombre",
|
||||
expect: "Dans les deux thèmes (bouton de la barre latérale), le contour se distingue nettement du fond de la page, des cartes et du contour de focus du clavier, sur les pages Import, Transactions et Budget." },
|
||||
{ id: "guided-keyboard", title: "Clavier seul",
|
||||
expect: "Sans souris, de l'étape 2 à l'étape 8 : Tab atteint « Étape suivante », « Voir la liste », « Masquer », les lignes de la liste, « J'ai compris », « Passer cette étape » et le contrôle entouré, et Entrée ou Espace les active. À l'arrivée par un lien d'étape, le focus est sur le titre de la carte, et Tab repart de là vers le contrôle entouré. Quand le contrôle entouré a le focus, l'anneau de focus du système remplace le contour. Le focus reste visible partout et n'est jamais pris au piège." },
|
||||
{ id: "guided-focus-after-hide", title: "Limite connue : le focus après « Masquer » et « Fermer »",
|
||||
expect: "Au clavier, active « Masquer » dans le bandeau, puis, sur un autre profil, « Fermer » au message de fin : le bouton disparaît avec le bandeau et le focus retombe sur la page elle-même, comme avec l'ancienne barre compacte ; note d'où Tab repart. Même chose après un lien du bandeau vers une page sans carte (« Ouvrir le guide », ligne Budget verrouillée). À juger, avec Orca puis NVDA : le lecteur d'écran perd-il le fil ?" },
|
||||
{ id: "guided-orca", title: "Annonces d'Orca (Linux)",
|
||||
expect: "Le bandeau et la carte s'annoncent comme la région « Parcours de démarrage » ; « Voir la liste » annonce son état replié ou déplié ; dans la liste, chaque étape est suivie de son état (« faite », « passée », « verrouillée », « en attente »), sauf une étape à faire ; à l'arrivée par « Étape suivante », le titre de la carte est lu ; « Fait. » est annoncé sans déplacer le focus ; la confirmation de Paramètres → Utilisateurs est annoncée." },
|
||||
{ id: "guided-nvda", title: "Annonces de NVDA (Windows)",
|
||||
expect: "Mêmes annonces que sous Orca : région « Parcours de démarrage », état replié ou déplié de « Voir la liste », état de chaque étape de la liste, titre de la carte à l'arrivée par « Étape suivante », « Fait. » sans déplacement du focus, confirmation de Paramètres → Utilisateurs." }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
var ALL_STEPS = [];
|
||||
SECTIONS.forEach(function (section) {
|
||||
section.steps.forEach(function (step) { ALL_STEPS.push(step); });
|
||||
});
|
||||
|
||||
var state = load();
|
||||
|
||||
function load() {
|
||||
var fresh = { meta: { tester: "", version: "", os: "" }, steps: {} };
|
||||
try {
|
||||
var raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return fresh;
|
||||
var parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object") return fresh;
|
||||
return {
|
||||
meta: Object.assign(fresh.meta, parsed.meta || {}),
|
||||
steps: parsed.steps && typeof parsed.steps === "object" ? parsed.steps : {}
|
||||
};
|
||||
} catch (e) {
|
||||
return fresh;
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (e) {
|
||||
setStatus("Impossible d'enregistrer dans ce navigateur : le rapport reste copiable, mais l'état sera perdu au rechargement.");
|
||||
}
|
||||
}
|
||||
|
||||
function stepState(id) {
|
||||
if (!state.steps[id]) state.steps[id] = { done: false, note: "" };
|
||||
return state.steps[id];
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
document.getElementById("status").textContent = text;
|
||||
}
|
||||
|
||||
function countDone() {
|
||||
var done = 0;
|
||||
ALL_STEPS.forEach(function (s) { if (stepState(s.id).done) done += 1; });
|
||||
return done;
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
document.getElementById("progress").textContent = countDone() + " / " + ALL_STEPS.length + " étapes cochées";
|
||||
}
|
||||
|
||||
function renderStep(step, list) {
|
||||
var st = stepState(step.id);
|
||||
var li = document.createElement("li");
|
||||
li.className = st.done ? "done" : "";
|
||||
|
||||
var check = document.createElement("div");
|
||||
check.className = "check";
|
||||
var box = document.createElement("input");
|
||||
box.type = "checkbox";
|
||||
box.id = "done-" + step.id;
|
||||
box.checked = !!st.done;
|
||||
box.addEventListener("change", function () {
|
||||
st.done = box.checked;
|
||||
li.className = st.done ? "done" : "";
|
||||
save();
|
||||
updateProgress();
|
||||
});
|
||||
check.appendChild(box);
|
||||
|
||||
var title = document.createElement("label");
|
||||
title.className = "title";
|
||||
title.htmlFor = box.id;
|
||||
title.textContent = step.title;
|
||||
|
||||
var expect = document.createElement("div");
|
||||
expect.className = "expect";
|
||||
expect.textContent = step.expect;
|
||||
|
||||
var where = document.createElement("div");
|
||||
where.className = "where";
|
||||
var noteLabel = document.createElement("label");
|
||||
noteLabel.htmlFor = "note-" + step.id;
|
||||
noteLabel.textContent = "Où as-tu hésité ?";
|
||||
var note = document.createElement("input");
|
||||
note.type = "text";
|
||||
note.id = noteLabel.htmlFor;
|
||||
note.value = st.note || "";
|
||||
note.autocomplete = "off";
|
||||
note.addEventListener("input", function () {
|
||||
st.note = note.value;
|
||||
save();
|
||||
});
|
||||
where.appendChild(noteLabel);
|
||||
where.appendChild(note);
|
||||
|
||||
li.appendChild(check);
|
||||
li.appendChild(title);
|
||||
li.appendChild(expect);
|
||||
li.appendChild(where);
|
||||
list.appendChild(li);
|
||||
}
|
||||
|
||||
function render() {
|
||||
var root = document.getElementById("sections");
|
||||
root.textContent = "";
|
||||
SECTIONS.forEach(function (section) {
|
||||
var block = document.createElement("section");
|
||||
block.id = section.id;
|
||||
|
||||
var heading = document.createElement("h2");
|
||||
heading.textContent = section.title;
|
||||
block.appendChild(heading);
|
||||
|
||||
var intro = document.createElement("p");
|
||||
intro.className = "intro";
|
||||
intro.textContent = section.intro;
|
||||
block.appendChild(intro);
|
||||
|
||||
var list = document.createElement("ol");
|
||||
list.className = "steps";
|
||||
section.steps.forEach(function (step) { renderStep(step, list); });
|
||||
block.appendChild(list);
|
||||
|
||||
root.appendChild(block);
|
||||
});
|
||||
|
||||
["tester", "version", "os"].forEach(function (key) {
|
||||
var input = document.getElementById(key);
|
||||
input.value = state.meta[key] || "";
|
||||
input.oninput = function () {
|
||||
state.meta[key] = input.value;
|
||||
save();
|
||||
};
|
||||
});
|
||||
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
function pad(n) { return n < 10 ? "0" + n : String(n); }
|
||||
|
||||
function buildReport() {
|
||||
var now = new Date();
|
||||
var date = now.getFullYear() + "-" + pad(now.getMonth() + 1) + "-" + pad(now.getDate()) +
|
||||
" " + pad(now.getHours()) + ":" + pad(now.getMinutes());
|
||||
var lines = [];
|
||||
lines.push("QA parcours d'accueil — " + date);
|
||||
if (state.meta.tester) lines.push("Testeur : " + state.meta.tester);
|
||||
if (state.meta.version) lines.push("Version : " + state.meta.version);
|
||||
if (state.meta.os) lines.push("Système : " + state.meta.os);
|
||||
lines.push("Étapes cochées : " + countDone() + " / " + ALL_STEPS.length);
|
||||
SECTIONS.forEach(function (section) {
|
||||
lines.push("");
|
||||
lines.push("== " + section.title + " ==");
|
||||
section.steps.forEach(function (step, i) {
|
||||
var st = stepState(step.id);
|
||||
lines.push((st.done ? "[x] " : "[ ] ") + (i + 1) + ". " + step.title);
|
||||
if (st.note && st.note.trim()) lines.push(" Hésitation : " + st.note.trim());
|
||||
});
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function showFallback(text) {
|
||||
var box = document.getElementById("fallback");
|
||||
var area = document.getElementById("report");
|
||||
area.value = text;
|
||||
box.hidden = false;
|
||||
area.focus();
|
||||
area.select();
|
||||
}
|
||||
|
||||
document.getElementById("copy").addEventListener("click", function () {
|
||||
var text = buildReport();
|
||||
document.getElementById("fallback").hidden = true;
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
|
||||
navigator.clipboard.writeText(text).then(function () {
|
||||
setStatus("Rapport copié. Colle-le dans Paramètres → Système → Envoyer un feedback.");
|
||||
}, function () {
|
||||
setStatus("Copie automatique refusée : utilise la zone ci-dessous.");
|
||||
showFallback(text);
|
||||
});
|
||||
} else {
|
||||
setStatus("Copie automatique indisponible : utilise la zone ci-dessous.");
|
||||
showFallback(text);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("reset").addEventListener("click", function () {
|
||||
if (!window.confirm("Effacer toutes les cases et tous les commentaires ?")) return;
|
||||
state = { meta: { tester: "", version: "", os: "" }, steps: {} };
|
||||
try { window.localStorage.removeItem(STORAGE_KEY); } catch (e) { /* nothing to remove */ }
|
||||
document.getElementById("fallback").hidden = true;
|
||||
setStatus("Checklist réinitialisée.");
|
||||
render();
|
||||
});
|
||||
|
||||
render();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# QA — Cycle de mise à jour automatique
|
||||
|
||||
Checklist manuelle pour valider qu'une release est réellement installable **par la mise à jour automatique**, et pas seulement téléchargeable. À dérouler après chaque release qui touche `tauri-plugin-updater`, ses dépendances (`reqwest`/`rustls-webpki`, `tar`), `release.yml`, ou la configuration `updater` de `tauri.conf.json`.
|
||||
Checklist manuelle pour valider qu'une release est réellement installable **par la mise à jour automatique**, et pas seulement téléchargeable. À dérouler après chaque release qui touche `tauri-plugin-updater`, ses dépendances (`reqwest`/`rustls`/`rustls-webpki`), `release.yml`, ou la configuration `updater` de `tauri.conf.json`.
|
||||
|
||||
Elle existe parce que la CI ne peut rien prouver ici : `cargo check` et `cargo test` prouvent que le chemin de mise à jour **compile**, jamais qu'il fonctionne. Une régression s'y manifeste chez l'utilisateur, au moment de la mise à jour — et la mise à jour automatique étant une fonctionnalité **Base+**, ce sont des utilisateurs payants.
|
||||
|
||||
|
|
@ -12,14 +12,14 @@ Elle existe parce que la CI ne peut rien prouver ici : `cargo check` et `cargo t
|
|||
|
||||
Ce sont les conditions sans lesquelles le test ne teste rien. Chacune a un mode d'échec où la checklist se coche « OK » sans avoir rien vérifié.
|
||||
|
||||
- [ ] **Une machine Windows** (hôte ou VM) et **une machine Linux de bureau**. Les deux flux sont réellement différents, ils ne se substituent pas l'un à l'autre.
|
||||
- [ ] Sur la machine Linux, **un agent polkit qui tourne**. L'installation du `.deb` passe par `pkexec` ; sans agent, l'app retombe en cascade sur `zenity`/`kdialog` puis sur un `sudo` dans un terminal inexistant, et **paraît figée au lieu d'échouer — sur l'écran de téléchargement, pas sur `readyToInstall`** (voir §2, c'est le piège qui produit le mauvais rapport de bug). Noter plus bas quelle invite est réellement apparue : si ce n'est pas polkit, c'est un chemin de repli qui a été validé, pas le chemin nominal.
|
||||
- [ ] **La version précédente installée sur chaque machine**, depuis les artefacts de la release précédente (`.deb` et `-setup.exe` attachés à la release Forgejo). Ne pas tester sur la machine qui vient de builder la nouvelle version.
|
||||
- [ ] **Une machine Windows** (hôte ou VM) et **une machine Linux de bureau**, plus, pour le §3, **une machine Fedora ou openSUSE** installée depuis le `.rpm`. Les flux sont réellement différents, ils ne se substituent pas l'un à l'autre.
|
||||
- [ ] Sur chaque machine Linux, **un agent polkit qui tourne**. L'installation du `.deb` comme du `.rpm` passe par `pkexec` ; sans agent, l'app retombe en cascade sur `zenity`/`kdialog` puis sur un `sudo` dans un terminal inexistant, et **paraît figée au lieu d'échouer — sur l'écran de téléchargement, pas sur `readyToInstall`** (voir §2, c'est le piège qui produit le mauvais rapport de bug). Noter plus bas quelle invite est réellement apparue : si ce n'est pas polkit, c'est un chemin de repli qui a été validé, pas le chemin nominal.
|
||||
- [ ] **La version précédente installée sur chaque machine**, depuis les artefacts de la release précédente (`.deb`, `.rpm` et `-setup.exe` attachés à la release Forgejo). Ne pas tester sur la machine qui vient de builder la nouvelle version.
|
||||
|
||||
> Sans ça, `check()` compare `release.version > version_courante`, trouve faux, et l'UI affiche « à jour ». La checklist se coche alors comme « rien à mettre à jour » — soit exactement le saut silencieux qu'elle est censée éliminer, avec en prime une trace écrite affirmant qu'elle est passée.
|
||||
|
||||
- [ ] **Une clé de licence Base ou Premium valide sur chaque machine.** La mise à jour automatique est verrouillée par édition : `useUpdater` appelle `check_entitlement("auto-update")` et bascule en `notEntitled` **avant** même d'appeler `check()`. Une installation Gratuite ne télécharge jamais rien, et les builds de release n'ont pas de `dev-override`.
|
||||
- [ ] Sur chaque machine, **supprimer tout `activation.token` provenant d'une autre machine** (VM clonée, snapshot restauré) dans le répertoire de données de l'app. Une `license.key` seule fonctionne — l'absence de token est un état de pré-activation toléré — mais un token émis pour un autre `machine_id` fait retomber l'édition en Gratuite, et le testeur voit une carte « non éligible » anodine plutôt qu'un échec.
|
||||
- [ ] Sur chaque machine, **supprimer tout `activation.token` provenant d'une autre machine** (VM clonée, snapshot restauré) dans le répertoire de données de l'app. Une `license.key` seule fonctionne — l'absence de token est un état de pré-activation toléré. Dans les versions antérieures à #347 (v0.15.0 et avant), un token émis pour un autre `machine_id` fait retomber l'édition en Gratuite, et le testeur voit une carte « non éligible » anodine plutôt qu'un échec ; depuis #347, il ne touche plus l'édition et la carte Licence affiche seulement « Non activée ». La suppression reste nécessaire tant que la version de départ du cycle est antérieure à #347.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -27,9 +27,9 @@ Ce sont les conditions sans lesquelles le test ne teste rien. Chacune a un mode
|
|||
|
||||
À écrire explicitement, parce que la tentation est de croire qu'un cycle réussi couvre tout le chemin de mise à jour.
|
||||
|
||||
**Prouvé** sur les deux cibles : le téléchargement TLS (donc `rustls-webpki`), la vérification de signature minisign, l'exécution de l'installeur, et le redémarrage sur la nouvelle version.
|
||||
**Prouvé** sur chaque cible déroulée : le téléchargement TLS (donc `rustls-webpki`), la vérification de signature minisign, l'exécution de l'installeur, et le redémarrage sur la nouvelle version.
|
||||
|
||||
**Non prouvé** : l'extraction d'archive, donc `tar`. Dans `tauri-plugin-updater`, `tar` n'est atteint que par `install_appimage` et la branche macOS `.app.tar.gz`. Aucun de nos bundles ne passe par là — le `.deb` fait `pkexec dpkg -i`, le NSIS écrit le `.exe` dans un répertoire temporaire et le lance. **Tant qu'on ne livre pas d'AppImage, le chemin `tar` reste mort** et aucune checklist manuelle ne le couvrira.
|
||||
**Non prouvé** : l'extraction d'archive, et pour cause : depuis #455, `tauri-plugin-updater` est compilé sans sa feature `zip`, donc sans `zip` sous Windows ni `tar` sous Linux. Avec `createUpdaterArtifacts: true`, aucun de nos paquets de mise à jour n'est une archive : le NSIS écrit le `.exe` dans un répertoire temporaire et le lance, le `.deb` passe par `pkexec dpkg -i` et le `.rpm` par `pkexec rpm -U` (§3) ; une AppImage brute, si on en livrait une, serait réécrite en place par la branche « rewriting AppImage », qui ne dépend pas de la feature. Seul `createUpdaterArtifacts: "v1Compatible"` produit des archives (`.nsis.zip`, `.msi.zip`, `.AppImage.tar.gz`). **Passer à ce mode exige de rétablir la feature** (`src-tauri/Cargo.toml`) : sans elle, un zip échoue sous Windows (`InvalidUpdaterFormat`, rien n'est installé) et un `.tar.gz` serait écrit par-dessus l'AppImage sous Linux. La branche macOS `.app.tar.gz` garde `tar`, mais l'app n'est pas livrée sur macOS.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -62,11 +62,40 @@ Point d'entrée : **Paramètres → Systèmes → carte Mises à jour → « Vé
|
|||
- [ ] Cliquer → état `installing`, l'app redémarre.
|
||||
- [ ] Vérifier la version, re-vérifier → `upToDate`.
|
||||
|
||||
## 3. RPM — cassé, connu
|
||||
## 3. Linux (.rpm)
|
||||
|
||||
- [ ] **Ne pas tester** : ce chemin est cassé, pas seulement non vérifié.
|
||||
Même parcours que le §2, sur la machine Fedora ou openSUSE. L'updater y lance `pkexec rpm -U` au lieu de `pkexec dpkg -i` (`install_rpm`, même cascade polkit → zenity/kdialog → `sudo`) : mêmes états, même moment pour l'invite, même piège.
|
||||
|
||||
`latest.json` ne porte qu'une entrée `linux-x86_64`, construite à partir du `.deb` (`release.yml:104-122`). Une installation rpm reçoit donc des octets deb, que `Installer::Rpm` rejette après vérification de la signature magique du payload. Les artefacts `.rpm` continuent pourtant d'être publiés comme assets de release. Suivi en **#320**.
|
||||
- [ ] Même point d'entrée ; la carte affiche `available` avec le **numéro de la nouvelle version**.
|
||||
- [ ] **Pendant que la carte affiche encore « Téléchargement en cours… »**, une invite d'authentification apparaît. **Noter laquelle.**
|
||||
|
||||
Invite observée : `________________`
|
||||
|
||||
- [ ] Authentifier → `readyToInstall`, puis cliquer → `installing`, l'app redémarre.
|
||||
- [ ] Vérifier la version, re-vérifier → `upToDate`.
|
||||
|
||||
> Une erreur `invalid updater binary format` sur la carte, juste après le téléchargement, est le défaut d'avant #320 : l'installation a reçu le `.deb` et l'a refusé. Vérifier que le `latest.json` publié porte la clé `linux-x86_64-rpm` (`verify-release.sh`, étape 9 de `/release`).
|
||||
|
||||
**Pas encore déroulé** : aucune machine Fedora ou openSUSE n'était disponible quand #320 a ajouté la clé `linux-x86_64-rpm`. Le dérouler dès qu'il y en a une ; d'ici là, l'écrire « non déroulé », avec la raison, dans la trace de la release.
|
||||
|
||||
Les installations `.rpm` existantes n'ont rien à réinstaller : le binaire du `.rpm` v0.17.1 publié porte le marqueur `__TAURI_BUNDLE_TYPE_VAR_RPM` (vérifié le 2026-10-10), donc il cherche `linux-x86_64-rpm` dès qu'une release publie cette clé. La première release qui la publie est la première que ces installations peuvent recevoir.
|
||||
|
||||
### Les clés de `latest.json`
|
||||
|
||||
`tauri-plugin-updater` cherche la clé `{os}-{arch}-{installer}`, puis `{os}-{arch}` (`get_urls`) ; l'installeur est le type de paquet que le bundler grave dans le binaire de chaque paquet. `scripts/build-latest-json.sh`, appelé par `release.yml`, écrit donc :
|
||||
|
||||
| Clé | Fichier | Lue par |
|
||||
|---|---|---|
|
||||
| `linux-x86_64-deb` | `.deb` | les installations `.deb` |
|
||||
| `linux-x86_64-rpm` | `.rpm` | les installations `.rpm` (#320) |
|
||||
| `windows-x86_64` | `-setup.exe` | les installations NSIS, qui cherchent d'abord `windows-x86_64-nsis` |
|
||||
|
||||
**Pas de clé générique `linux-x86_64`** (retirée le 2026-10-11, décision de Max sur #320). Toutes les versions qui embarquent l'updater (v0.2.1 et suivantes) verrouillent `tauri-plugin-updater` 2.10.0 et `tauri-utils` 2.8.2, et leurs paquets portent le marqueur de type (vérifié dans le `.deb` et le `.rpm` de la v0.17.1) : une installation `.deb` ou `.rpm` trouve sa clé avant de chercher `linux-x86_64`. Cette clé servait le `.deb`, et ses derniers lecteurs ne savaient pas l'installer :
|
||||
|
||||
- les AppImage des v0.4.3 à v0.4.5, publiées sur Forgejo pour la v0.4.3 et sur le miroir GitHub pour les v0.4.3 à v0.4.5. Leur updater lit le registre et cherche `linux-x86_64-appimage`, absente, puis `linux-x86_64`. La v0.4.2 vise `…/releases/latest/download/latest.json`, qui répond 404 sur cette instance Forgejo, et les versions antérieures lisent GitHub ;
|
||||
- un binaire lancé hors de son paquet, sans marqueur, qui ne cherche que `linux-x86_64`. C'est le cas d'un build `npm run tauri dev` sous Linux : sa carte Mises à jour affiche l'erreur `TargetsNotFound` à chaque vérification. Elle est attendue depuis le retrait de la clé, ce n'est pas une régression (#419).
|
||||
|
||||
Avec la clé, l'updater les traitait en AppImage (`install_appimage`) et écrivait les octets du `.deb` à la place du fichier lancé, ce qui cassait l'installation. Sans elle, leur vérification échoue avec `TargetsNotFound` et rien n'est installé : la carte Mises à jour affiche une erreur. La voie, pour ces installations, est de télécharger le `.deb` ou le `.rpm` de la dernière release.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -76,7 +105,7 @@ L'ordre compte, et la première action ne répare pas ce qu'on croit.
|
|||
|
||||
1. **Republier le `latest.json` précédent** sur `generic/simpl-resultat/latest` (`DELETE` puis `PUT`, avec le `PACKAGE_TOKEN` qu'utilise `release.yml`). Le corps est récupérable depuis les assets de la release précédente — sa copie dans le registre a été détruite par le `DELETE` que `release.yml` fait avant chaque envoi.
|
||||
|
||||
**Les deux verbes ne visent pas le même préfixe d'API**, et `release.yml:217-219` porte un commentaire explicite là-dessus — le piège est déjà tombé une fois :
|
||||
**Les deux verbes ne visent pas le même préfixe d'API**, et l'étape « Publish latest.json to package registry » de `release.yml` porte un commentaire explicite là-dessus — le piège est déjà tombé une fois :
|
||||
|
||||
```
|
||||
DELETE {serveur}/api/v1/packages/{owner}/generic/simpl-resultat/latest
|
||||
|
|
@ -93,5 +122,12 @@ L'ordre compte, et la première action ne répare pas ce qu'on croit.
|
|||
|
||||
## Trace
|
||||
|
||||
- [ ] Consigner le résultat en commentaire de la release Forgejo : cibles déroulées, versions de départ et d'arrivée, invite d'authentification observée sous Linux.
|
||||
- [ ] Consigner le résultat **dans le corps de la release Forgejo**, en section « QA — cycle de mise à jour automatique » ajoutée sous les notes : cibles déroulées, versions de départ et d'arrivée, invite d'authentification observée sous Linux. Forgejo n'a pas de commentaires sur les releases, et éditer le corps ne touche pas `latest.json`, déjà publié dans le registre de paquets. Le corps est **remplacé en entier** par l'API — relire, concaténer, puis :
|
||||
|
||||
```bash
|
||||
# id : GET /releases/tags/vX.Y.Z → .id ; payload.json = {"body": "<notes existantes + section QA>"}
|
||||
curl -X PATCH -H "Authorization: token $(cat ~/.forgejo-token)" -H "Content-Type: application/json" \
|
||||
--data-binary @payload.json "https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-resultat/releases/{id}"
|
||||
```
|
||||
|
||||
- [ ] Si la checklist **n'a pas été déroulée**, l'écrire explicitement avec la raison, plutôt que de laisser le silence. Une étape non tracée est indiscernable d'une étape passée — c'est le mode d'échec que cette page combat.
|
||||
|
|
|
|||
187
package-lock.json
generated
187
package-lock.json
generated
|
|
@ -1,18 +1,19 @@
|
|||
{
|
||||
"name": "simpl_result_scaffold",
|
||||
"version": "0.15.0",
|
||||
"version": "0.17.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "simpl_result_scaffold",
|
||||
"version": "0.15.0",
|
||||
"version": "0.17.2",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-sql": "^2.3.2",
|
||||
|
|
@ -1767,6 +1768,15 @@
|
|||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz",
|
||||
|
|
@ -1980,16 +1990,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
|
||||
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
|
||||
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
|
|
@ -1998,13 +2008,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
||||
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
|
||||
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
|
|
@ -2025,9 +2035,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
|
||||
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
|
||||
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2038,13 +2048,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
|
||||
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
|
||||
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.9",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
|
|
@ -2052,14 +2062,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
|
||||
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
|
||||
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
|
|
@ -2068,9 +2078,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
|
||||
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
|
||||
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
|
|
@ -2078,13 +2088,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
|
||||
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
|
||||
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
|
|
@ -2103,9 +2113,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.41",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz",
|
||||
"integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==",
|
||||
"version": "2.11.28",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz",
|
||||
"integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
|
@ -2116,9 +2126,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.4",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
|
||||
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
|
||||
"version": "4.29.3",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz",
|
||||
"integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -2136,11 +2146,11 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.38",
|
||||
"caniuse-lite": "^1.0.30001799",
|
||||
"electron-to-chromium": "^1.5.376",
|
||||
"node-releases": "^2.0.48",
|
||||
"update-browserslist-db": "^1.2.3"
|
||||
"baseline-browser-mapping": "^2.11.26",
|
||||
"caniuse-lite": "^1.0.30001813",
|
||||
"electron-to-chromium": "^1.5.439",
|
||||
"node-releases": "^2.0.57",
|
||||
"update-browserslist-db": "^1.3.3"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
|
|
@ -2150,9 +2160,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001800",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
|
||||
"integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==",
|
||||
"version": "1.0.30001815",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz",
|
||||
"integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -2171,9 +2181,9 @@
|
|||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
|
||||
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -2356,9 +2366,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.384",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.384.tgz",
|
||||
"integrity": "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==",
|
||||
"version": "1.5.452",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz",
|
||||
"integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
|
|
@ -2889,9 +2899,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.20",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -2908,9 +2918,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.50",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
|
||||
"integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
|
||||
"version": "2.0.58",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz",
|
||||
"integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -3071,9 +3081,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.18.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
|
||||
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
|
||||
"version": "7.18.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
|
||||
"integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
|
|
@ -3093,12 +3103,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.18.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
|
||||
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
|
||||
"version": "7.18.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
|
||||
"integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.18.1"
|
||||
"react-router": "7.18.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
|
@ -3226,10 +3236,11 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -3306,9 +3317,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz",
|
||||
"integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -3340,9 +3351,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz",
|
||||
"integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -3475,19 +3486,19 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
|
||||
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
|
||||
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.9",
|
||||
"@vitest/mocker": "4.1.9",
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/runner": "4.1.9",
|
||||
"@vitest/snapshot": "4.1.9",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"@vitest/expect": "4.1.11",
|
||||
"@vitest/mocker": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/runner": "4.1.11",
|
||||
"@vitest/snapshot": "4.1.11",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
|
|
@ -3515,12 +3526,12 @@
|
|||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.9",
|
||||
"@vitest/browser-preview": "4.1.9",
|
||||
"@vitest/browser-webdriverio": "4.1.9",
|
||||
"@vitest/coverage-istanbul": "4.1.9",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"@vitest/ui": "4.1.9",
|
||||
"@vitest/browser-playwright": "4.1.11",
|
||||
"@vitest/browser-preview": "4.1.11",
|
||||
"@vitest/browser-webdriverio": "4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.11",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@vitest/ui": "4.1.11",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "simpl_result_scaffold",
|
||||
"private": true,
|
||||
"version": "0.15.0",
|
||||
"version": "0.17.2",
|
||||
"license": "GPL-3.0-only",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-sql": "^2.3.2",
|
||||
|
|
|
|||
116
scripts/build-latest-json.sh
Executable file
116
scripts/build-latest-json.sh
Executable file
|
|
@ -0,0 +1,116 @@
|
|||
#!/usr/bin/env bash
|
||||
# Build latest.json, the updater manifest of a release, from its assets (#320).
|
||||
#
|
||||
# Usage: build-latest-json.sh <assets-dir> <version> <notes-file> <base-url>
|
||||
#
|
||||
# assets-dir directory holding the bundles and their .sig files
|
||||
# version release version without the leading v, e.g. 0.17.1
|
||||
# notes-file release notes, embedded verbatim as data (jq --rawfile)
|
||||
# base-url download URL of the release, without a trailing slash, e.g.
|
||||
# https://<forgejo>/<owner>/<repo>/releases/download/v0.17.1
|
||||
#
|
||||
# Writes the JSON on stdout and its log on stderr. release.yml redirects
|
||||
# stdout to release-assets/latest.json; scripts/build-latest-json.test.mjs
|
||||
# runs it under bash -e -o pipefail, as the CI runner does.
|
||||
#
|
||||
# Fails closed: unless the assets dir holds exactly one .deb, one .rpm and
|
||||
# one -setup.exe, each with a non-empty "<asset>.sig" next to it, the script
|
||||
# exits non-zero before writing anything on stdout. The JSON is built by jq
|
||||
# alone: notes, file names and signatures enter it as jq values, never as
|
||||
# text spliced into JSON.
|
||||
#
|
||||
# Keys. tauri-plugin-updater 2.x looks up "{os}-{arch}-{installer}" first,
|
||||
# then "{os}-{arch}" (get_urls in its updater.rs). The installer is the
|
||||
# bundle type that the bundler writes into the binary of each package.
|
||||
#
|
||||
# linux-x86_64-deb the .deb, for .deb installs.
|
||||
# linux-x86_64-rpm the .rpm, for .rpm installs. Without this key they fell
|
||||
# back on linux-x86_64, downloaded the .deb and refused it.
|
||||
# windows-x86_64 the NSIS installer. An NSIS install looks up
|
||||
# windows-x86_64-nsis first, then this key.
|
||||
#
|
||||
# No generic linux-x86_64 key since 2026-10-11 (Max's decision on #320). Every
|
||||
# version that ships the updater (v0.2.1 and later, tauri-plugin-updater
|
||||
# 2.10.0) carries its bundle-type marker, so .deb and .rpm installs never
|
||||
# reach that key. Its only readers were the AppImage installs v0.4.3 to
|
||||
# v0.4.5 and binaries run outside a package, and the .deb it served overwrote
|
||||
# them: they now get TargetsNotFound when checking, nothing installed. See
|
||||
# docs/qa-update-cycle.md, "Les clés de latest.json".
|
||||
#
|
||||
# Each URL ends with the file name percent-encoded by jq's @uri, as the upload
|
||||
# step of release.yml encodes it: the bundle names contain a space.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
die() {
|
||||
printf 'build-latest-json: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ "$#" -eq 4 ] || die "usage: build-latest-json.sh <assets-dir> <version> <notes-file> <base-url>"
|
||||
assets_dir=$1
|
||||
version=$2
|
||||
notes_file=$3
|
||||
base_url=$4
|
||||
|
||||
[ -d "$assets_dir" ] || die "assets dir not found: $assets_dir"
|
||||
[ -n "$version" ] || die "empty version"
|
||||
[ -f "$notes_file" ] || die "notes file not found: $notes_file"
|
||||
[ -n "$base_url" ] || die "empty base URL"
|
||||
|
||||
# read_asset <kind> <matches...>: the single bundle of a kind and its
|
||||
# signature, in asset_name and asset_sig. Any other count of matches, or a
|
||||
# missing or blank "<asset>.sig", stops the script.
|
||||
asset_name=""
|
||||
asset_sig=""
|
||||
read_asset() {
|
||||
local kind=$1
|
||||
shift
|
||||
if [ "$#" -ne 1 ]; then
|
||||
printf 'build-latest-json: expected exactly one %s in %s, found %d\n' "$kind" "$assets_dir" "$#" >&2
|
||||
[ "$#" -eq 0 ] || printf ' %s\n' "$@" >&2
|
||||
exit 1
|
||||
fi
|
||||
local file=$1
|
||||
[ -s "$file.sig" ] || die "missing or empty signature: $file.sig"
|
||||
asset_name=${file##*/}
|
||||
asset_sig=$(cat "$file.sig")
|
||||
[ -n "$asset_sig" ] || die "blank signature: $file.sig"
|
||||
printf 'build-latest-json: %s <- %s\n' "$kind" "$asset_name" >&2
|
||||
}
|
||||
|
||||
shopt -s nullglob
|
||||
|
||||
read_asset .deb "$assets_dir"/*.deb
|
||||
deb_name=$asset_name
|
||||
deb_sig=$asset_sig
|
||||
|
||||
read_asset .rpm "$assets_dir"/*.rpm
|
||||
rpm_name=$asset_name
|
||||
rpm_sig=$asset_sig
|
||||
|
||||
read_asset -setup.exe "$assets_dir"/*-setup.exe
|
||||
exe_name=$asset_name
|
||||
exe_sig=$asset_sig
|
||||
|
||||
pub_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
jq -n \
|
||||
--arg version "$version" \
|
||||
--rawfile notes "$notes_file" \
|
||||
--arg pub_date "$pub_date" \
|
||||
--arg base "$base_url" \
|
||||
--arg deb "$deb_name" --arg deb_sig "$deb_sig" \
|
||||
--arg rpm "$rpm_name" --arg rpm_sig "$rpm_sig" \
|
||||
--arg exe "$exe_name" --arg exe_sig "$exe_sig" \
|
||||
'def asset($name; $sig): {signature: $sig, url: ($base + "/" + ($name | @uri))};
|
||||
{
|
||||
version: $version,
|
||||
notes: $notes,
|
||||
pub_date: $pub_date,
|
||||
platforms: {
|
||||
"linux-x86_64-deb": asset($deb; $deb_sig),
|
||||
"linux-x86_64-rpm": asset($rpm; $rpm_sig),
|
||||
"windows-x86_64": asset($exe; $exe_sig)
|
||||
}
|
||||
}'
|
||||
462
scripts/build-latest-json.test.mjs
Normal file
462
scripts/build-latest-json.test.mjs
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
/**
|
||||
* scripts/build-latest-json.sh — the updater manifest of a release (#320).
|
||||
*
|
||||
* release.yml runs this script to write latest.json, which every installed
|
||||
* app reads to find its update. tauri-plugin-updater looks up
|
||||
* "{os}-{arch}-{installer}" first, then "{os}-{arch}". Before #320 the
|
||||
* manifest only had linux-x86_64 (the .deb), so an .rpm install downloaded
|
||||
* the .deb and refused it. Since 2026-10-11 it has no linux-x86_64 key at
|
||||
* all: its last readers (old AppImage installs, binaries run outside a
|
||||
* package) could not install the .deb it served.
|
||||
*
|
||||
* Each test runs the script as release.yml does, under bash -e -o pipefail
|
||||
* (the shell of the runner's steps), on a temporary assets directory. The
|
||||
* bundle names are the real ones of v0.17.1: they contain a space, which the
|
||||
* URLs must percent-encode. Expected values are literals, never rebuilt from
|
||||
* the inputs.
|
||||
*
|
||||
* It also runs scripts/check-latest-json.jq, the filter release.yml applies
|
||||
* before deleting the published manifest, so that check is exercised on every
|
||||
* pull request instead of only at the tag, and compares the key lists of the
|
||||
* script, of that filter and of verify-release.sh. Last, it runs the text of
|
||||
* release.yml's "Publish latest.json to package registry" step against a fake
|
||||
* curl, so the way the step calls the filter is tested too (#419).
|
||||
*
|
||||
* check-frontend.yml does not ignore .claude/** nor release.yml for that
|
||||
* reason: a pull request that only touches verify-release.sh or release.yml
|
||||
* still runs this file.
|
||||
*
|
||||
* Plain .mjs on purpose: tsconfig only covers src/, so a .ts file here would
|
||||
* look type-checked without being so. Needs bash and jq on PATH;
|
||||
* check-frontend.yml installs jq for this file.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, copyFileSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SCRIPT = join(HERE, "build-latest-json.sh");
|
||||
const CHECK = join(HERE, "check-latest-json.jq");
|
||||
const VERIFY_RELEASE = join(HERE, "..", ".claude", "skills", "release", "scripts", "verify-release.sh");
|
||||
const RELEASE_YML = join(HERE, "..", ".forgejo", "workflows", "release.yml");
|
||||
const BASE = "https://git.example.test/owner/Repo/releases/download/v0.17.1";
|
||||
|
||||
const DEB = "Simpl Resultat_0.17.1_amd64.deb";
|
||||
const RPM = "Simpl Resultat-0.17.1-1.x86_64.rpm";
|
||||
const EXE = "Simpl Resultat_0.17.1_x64-setup.exe";
|
||||
|
||||
// One distinct signature per bundle, to tell which .sig each key carries.
|
||||
const SIG = {
|
||||
[DEB]: "RWRkZWItc2lnbmF0dXJl",
|
||||
[RPM]: "RWRycG0tc2lnbmF0dXJl",
|
||||
[EXE]: "RWRleGUtc2lnbmF0dXJl",
|
||||
};
|
||||
|
||||
let work;
|
||||
let assets;
|
||||
let notesFile;
|
||||
|
||||
function writeAsset(name, sig = SIG[name]) {
|
||||
writeFileSync(join(assets, name), `bytes of ${name}\n`);
|
||||
if (sig !== null) writeFileSync(join(assets, `${name}.sig`), sig);
|
||||
}
|
||||
|
||||
function writeAllAssets() {
|
||||
for (const name of [DEB, RPM, EXE]) writeAsset(name);
|
||||
}
|
||||
|
||||
function run(args = [assets, "0.17.1", notesFile, BASE]) {
|
||||
return spawnSync("bash", ["-e", "-o", "pipefail", SCRIPT, ...args], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
function expectFailure(result, message) {
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stdout).toBe("");
|
||||
expect(result.stderr).toContain(message);
|
||||
}
|
||||
|
||||
// The manifest the script writes for the three bundles of v0.17.1.
|
||||
function builtManifest() {
|
||||
writeAllAssets();
|
||||
const result = run();
|
||||
expect(result.status).toBe(0);
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
// Runs scripts/check-latest-json.jq with the options release.yml passes, on a
|
||||
// manifest written to a file: an object is serialized, a string is written as
|
||||
// is. The "release.yml Publish step" tests below run the step's own text.
|
||||
function check(manifest, version = "0.17.1") {
|
||||
const file = join(work, "latest.json");
|
||||
writeFileSync(file, typeof manifest === "string" ? manifest : JSON.stringify(manifest));
|
||||
return spawnSync("jq", ["-e", "-s", "--arg", "version", version, "-f", CHECK, file], { encoding: "utf8" });
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
const jq = spawnSync("jq", ["--version"], { encoding: "utf8" });
|
||||
if (jq.status !== 0) {
|
||||
throw new Error("jq not found: build-latest-json.sh needs it (check-frontend.yml installs it)");
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
work = mkdtempSync(join(tmpdir(), "build-latest-json-"));
|
||||
assets = join(work, "release-assets");
|
||||
mkdirSync(assets);
|
||||
notesFile = join(work, "notes.md");
|
||||
writeFileSync(notesFile, "### Fixed\n\n- Something (#320).");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("build-latest-json.sh", () => {
|
||||
it("writes the three keys, each with its own URL and its own asset's signature", () => {
|
||||
writeAllAssets();
|
||||
|
||||
const result = run();
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const manifest = JSON.parse(result.stdout);
|
||||
expect(manifest.version).toBe("0.17.1");
|
||||
expect(manifest.notes).toBe("### Fixed\n\n- Something (#320).");
|
||||
expect(manifest.pub_date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
|
||||
// No generic linux-x86_64: an install without a key of its own must fail
|
||||
// its check (TargetsNotFound) rather than receive the .deb.
|
||||
expect(manifest.platforms).toStrictEqual({
|
||||
"linux-x86_64-deb": {
|
||||
signature: "RWRkZWItc2lnbmF0dXJl",
|
||||
url: "https://git.example.test/owner/Repo/releases/download/v0.17.1/Simpl%20Resultat_0.17.1_amd64.deb",
|
||||
},
|
||||
"linux-x86_64-rpm": {
|
||||
signature: "RWRycG0tc2lnbmF0dXJl",
|
||||
url: "https://git.example.test/owner/Repo/releases/download/v0.17.1/Simpl%20Resultat-0.17.1-1.x86_64.rpm",
|
||||
},
|
||||
"windows-x86_64": {
|
||||
signature: "RWRleGUtc2lnbmF0dXJl",
|
||||
url: "https://git.example.test/owner/Repo/releases/download/v0.17.1/Simpl%20Resultat_0.17.1_x64-setup.exe",
|
||||
},
|
||||
});
|
||||
// The step log names the bundle picked for each kind.
|
||||
expect(result.stderr).toContain("build-latest-json: .rpm <- Simpl Resultat-0.17.1-1.x86_64.rpm");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[".deb", DEB],
|
||||
[".rpm", RPM],
|
||||
["-setup.exe", EXE],
|
||||
])("fails without a %s bundle, writing nothing on stdout", (kind, missing) => {
|
||||
for (const name of [DEB, RPM, EXE]) {
|
||||
if (name !== missing) writeAsset(name);
|
||||
}
|
||||
|
||||
expectFailure(run(), `expected exactly one ${kind} in ${assets}, found 0`);
|
||||
});
|
||||
|
||||
it("fails on two .deb bundles", () => {
|
||||
writeAllAssets();
|
||||
writeAsset("Simpl Resultat_0.17.0_amd64.deb", "RWRvbGQtZGViLXNpZ25hdHVyZQ==");
|
||||
|
||||
expectFailure(run(), `expected exactly one .deb in ${assets}, found 2`);
|
||||
});
|
||||
|
||||
it("serves Windows the NSIS -setup.exe only, not another .exe", () => {
|
||||
writeAllAssets();
|
||||
writeAsset("simpl-result.exe", "RWRhcHAtZXhlLXNpZ25hdHVyZQ==");
|
||||
|
||||
const result = run();
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(JSON.parse(result.stdout).platforms["windows-x86_64"]).toStrictEqual({
|
||||
signature: "RWRleGUtc2lnbmF0dXJl",
|
||||
url: "https://git.example.test/owner/Repo/releases/download/v0.17.1/Simpl%20Resultat_0.17.1_x64-setup.exe",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails on an empty .sig", () => {
|
||||
writeAllAssets();
|
||||
writeFileSync(join(assets, `${RPM}.sig`), "");
|
||||
|
||||
expectFailure(run(), `missing or empty signature: ${assets}/${RPM}.sig`);
|
||||
});
|
||||
|
||||
it("fails on a .sig holding only line breaks", () => {
|
||||
writeAllAssets();
|
||||
writeFileSync(join(assets, `${EXE}.sig`), "\n\n");
|
||||
|
||||
expectFailure(run(), `blank signature: ${assets}/${EXE}.sig`);
|
||||
});
|
||||
|
||||
it("fails on a missing .sig", () => {
|
||||
writeAllAssets();
|
||||
unlinkSync(join(assets, `${DEB}.sig`));
|
||||
|
||||
expectFailure(run(), `missing or empty signature: ${assets}/${DEB}.sig`);
|
||||
});
|
||||
|
||||
it("keeps notes with quotes, backslashes and a CHANGELOG_EOF line intact", () => {
|
||||
writeAllAssets();
|
||||
const notes =
|
||||
'### Fixed\n\n- A "quoted" word, a back\\slash and C:\\Users\\path.\n' +
|
||||
"CHANGELOG_EOF\n" +
|
||||
'"version": "9.9.9", "platforms": {}\n';
|
||||
writeFileSync(notesFile, notes);
|
||||
|
||||
const result = run();
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const manifest = JSON.parse(result.stdout);
|
||||
expect(manifest.notes).toBe(
|
||||
'### Fixed\n\n- A "quoted" word, a back\\slash and C:\\Users\\path.\n' +
|
||||
"CHANGELOG_EOF\n" +
|
||||
'"version": "9.9.9", "platforms": {}\n',
|
||||
);
|
||||
expect(manifest.version).toBe("0.17.1");
|
||||
expect(Object.keys(manifest.platforms)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("fails without its four arguments", () => {
|
||||
writeAllAssets();
|
||||
|
||||
expectFailure(run([assets, "0.17.1", notesFile]), "usage: build-latest-json.sh");
|
||||
});
|
||||
|
||||
// The other tests pass 0.17.1, the version the bundle names also carry: a
|
||||
// script that ignored its argument would pass them all.
|
||||
it("takes the version from its argument, not from the bundle names", () => {
|
||||
writeAllAssets();
|
||||
|
||||
const result = run([assets, "0.18.0", notesFile, BASE]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(JSON.parse(result.stdout).version).toBe("0.18.0");
|
||||
});
|
||||
|
||||
it("fails on an empty version", () => {
|
||||
writeAllAssets();
|
||||
|
||||
expectFailure(run([assets, "", notesFile, BASE]), "empty version");
|
||||
});
|
||||
});
|
||||
|
||||
describe("check-latest-json.jq", () => {
|
||||
it("passes the script's output for the tag's version", () => {
|
||||
const result = check(builtManifest());
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toBe("true\n");
|
||||
});
|
||||
|
||||
it("refuses a manifest whose version is not the tag's", () => {
|
||||
expect(check(builtManifest(), "0.18.0").status).not.toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a missing key", (m) => delete m.platforms["linux-x86_64-rpm"]],
|
||||
["an extra key", (m) => (m.platforms["linux-x86_64-appimage"] = m.platforms["linux-x86_64-deb"])],
|
||||
["the generic linux-x86_64 key, removed on 2026-10-11", (m) => (m.platforms["linux-x86_64"] = m.platforms["linux-x86_64-deb"])],
|
||||
["the .rpm key on the .deb", (m) => (m.platforms["linux-x86_64-rpm"] = m.platforms["linux-x86_64-deb"])],
|
||||
["the .deb key on the .rpm", (m) => (m.platforms["linux-x86_64-deb"] = m.platforms["linux-x86_64-rpm"])],
|
||||
["the Windows key on a .sig", (m) => (m.platforms["windows-x86_64"].url += ".sig")],
|
||||
["an empty signature", (m) => (m.platforms["linux-x86_64-deb"].signature = "")],
|
||||
["a signature that is not a string", (m) => (m.platforms["windows-x86_64"].signature = 42)],
|
||||
["a key without a URL", (m) => delete m.platforms["linux-x86_64-rpm"].url],
|
||||
["a manifest without platforms", (m) => delete m.platforms],
|
||||
])("refuses %s", (_, mutate) => {
|
||||
const manifest = builtManifest();
|
||||
mutate(manifest);
|
||||
|
||||
expect(check(manifest).status).not.toBe(0);
|
||||
});
|
||||
|
||||
it("refuses the shape published up to v0.17.1, without a key per Linux package", () => {
|
||||
const published = {
|
||||
version: "0.17.1",
|
||||
notes: "### Fixed",
|
||||
pub_date: "2026-10-09T01:44:40Z",
|
||||
platforms: {
|
||||
"linux-x86_64": { signature: "RWRkZWItc2lnbmF0dXJl", url: `${BASE}/Simpl%20Resultat_0.17.1_amd64.deb` },
|
||||
"windows-x86_64": { signature: "RWRleGUtc2lnbmF0dXJl", url: `${BASE}/Simpl%20Resultat_0.17.1_x64-setup.exe` },
|
||||
},
|
||||
};
|
||||
|
||||
expect(check(published).status).not.toBe(0);
|
||||
});
|
||||
|
||||
it("refuses a file that is not JSON", () => {
|
||||
expect(check('{"version": "0.17.1", "platforms": ').status).not.toBe(0);
|
||||
});
|
||||
|
||||
// Without -s, jq 1.6 (the CI's and the release container's) produces no
|
||||
// output for an empty file, which -e lets through with status 0 (#419).
|
||||
it("refuses an empty file", () => {
|
||||
expect(check("").status).not.toBe(0);
|
||||
});
|
||||
|
||||
// Without -s, jq judges each document and its exit status follows the last
|
||||
// one, under 1.6 and 1.7 alike: a valid last document hid a broken first one.
|
||||
it("refuses a file with two documents, even when the last one is valid", () => {
|
||||
const valid = JSON.stringify(builtManifest());
|
||||
|
||||
expect(check(`{"version": "0.0.0"}\n${valid}\n`).status).not.toBe(0);
|
||||
expect(check(`${valid}\n${valid}\n`).status).not.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("key lists", () => {
|
||||
it("are the same in the script, in check-latest-json.jq and in verify-release.sh", () => {
|
||||
const manifest = builtManifest();
|
||||
const written = Object.keys(manifest.platforms).sort();
|
||||
|
||||
// The filter compares the exact key set: passing the script's output
|
||||
// proves its list is the script's.
|
||||
expect(check(manifest).status).toBe(0);
|
||||
// verify-release.sh keeps its own list, in a Python dict literal.
|
||||
const dict = readFileSync(VERIFY_RELEASE, "utf8").match(/^EXPECTED = \{([^}]*)\}/m);
|
||||
expect(dict).not.toBeNull();
|
||||
const expected = [...dict[1].matchAll(/"([a-z0-9_-]+)":/g)].map((m) => m[1]).sort();
|
||||
expect(expected).toStrictEqual(written);
|
||||
});
|
||||
});
|
||||
|
||||
// The text of release.yml's "Publish latest.json to package registry" step,
|
||||
// dedented. A whitespace-only line stays in the block; the block ends at the
|
||||
// first non-blank line indented no deeper than "run:", or at the end of file.
|
||||
function publishStepScript() {
|
||||
const lines = readFileSync(RELEASE_YML, "utf8").split("\n");
|
||||
const step = lines.findIndex((l) => /^\s*- name: Publish latest\.json to package registry\s*$/.test(l));
|
||||
expect(step).toBeGreaterThan(-1);
|
||||
const runAt = lines.findIndex((l, i) => i > step && /^\s*run: \|\s*$/.test(l));
|
||||
expect(runAt).toBeGreaterThan(step);
|
||||
const runIndent = lines[runAt].search(/\S/);
|
||||
const block = [];
|
||||
for (const line of lines.slice(runAt + 1)) {
|
||||
if (line.trim() !== "" && line.search(/\S/) <= runIndent) break;
|
||||
block.push(line);
|
||||
}
|
||||
while (block.length > 0 && block[block.length - 1].trim() === "") block.pop();
|
||||
const indent = block[0].search(/\S/);
|
||||
return block.map((l) => l.slice(indent)).join("\n") + "\n";
|
||||
}
|
||||
|
||||
describe("release.yml Publish step", () => {
|
||||
const SERVER = "https://git.example.test";
|
||||
const DELETE_URL = `${SERVER}/api/v1/packages/owner/generic/simpl-resultat/latest`;
|
||||
const PUT_URL = `${SERVER}/api/packages/owner/generic/simpl-resultat/latest/latest.json`;
|
||||
let curlLog;
|
||||
|
||||
// A curl that records "METHOD URL" (and the uploaded file for a PUT) and
|
||||
// prints the status code the step reads through -w: 204 to the DELETE, 201
|
||||
// to the PUT. It never touches the network.
|
||||
function writeFakeCurl(bin) {
|
||||
const curl = join(bin, "curl");
|
||||
writeFileSync(
|
||||
curl,
|
||||
`#!/usr/bin/env bash
|
||||
method=GET url= data=
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-X) method="$2"; shift ;;
|
||||
-H|-o|-w) shift ;;
|
||||
--data-binary) data="$2"; shift ;;
|
||||
-*) ;;
|
||||
*) url="$1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
if [ "$method" = PUT ]; then
|
||||
[ -f "\${data#@}" ] || { echo "fake curl: no file \${data#@}" >&2; exit 2; }
|
||||
echo "PUT $url \${data#@}" >> "$CURL_LOG"
|
||||
printf 201
|
||||
else
|
||||
echo "$method $url" >> "$CURL_LOG"
|
||||
printf 204
|
||||
fi
|
||||
`,
|
||||
);
|
||||
chmodSync(curl, 0o755);
|
||||
}
|
||||
|
||||
// Runs the step's text as the runner's default "shell: bash" does, in a
|
||||
// directory laid out like the checkout: scripts/check-latest-json.jq and
|
||||
// release-assets/latest.json.
|
||||
function publish(manifest, tag = "v0.17.1") {
|
||||
const bin = join(work, "bin");
|
||||
mkdirSync(bin);
|
||||
writeFakeCurl(bin);
|
||||
mkdirSync(join(work, "scripts"));
|
||||
copyFileSync(CHECK, join(work, "scripts", "check-latest-json.jq"));
|
||||
writeFileSync(
|
||||
join(assets, "latest.json"),
|
||||
typeof manifest === "string" ? manifest : JSON.stringify(manifest),
|
||||
);
|
||||
curlLog = join(work, "curl.log");
|
||||
return spawnSync("bash", ["--noprofile", "--norc", "-e", "-o", "pipefail", "-c", publishStepScript()], {
|
||||
cwd: work,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
CURL_LOG: curlLog,
|
||||
GITHUB_REF_NAME: tag,
|
||||
GITHUB_SERVER_URL: SERVER,
|
||||
GITHUB_REPOSITORY_OWNER: "owner",
|
||||
FORGEJO_TOKEN: "fake-token",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function curlCalls() {
|
||||
return existsSync(curlLog) ? readFileSync(curlLog, "utf8").trim().split("\n") : [];
|
||||
}
|
||||
|
||||
it("is extracted whole, without workflow expressions", () => {
|
||||
const script = publishStepScript();
|
||||
|
||||
expect(script).toContain("-f scripts/check-latest-json.jq");
|
||||
expect(script).toContain("-X DELETE");
|
||||
expect(script).toContain("-X PUT");
|
||||
// The runner would substitute them before bash runs; this test would not.
|
||||
expect(script).not.toContain("${{");
|
||||
});
|
||||
|
||||
it("deletes the published manifest, then uploads the new one", () => {
|
||||
const result = publish(builtManifest());
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(curlCalls()).toStrictEqual([
|
||||
`DELETE ${DELETE_URL}`,
|
||||
`PUT ${PUT_URL} release-assets/latest.json`,
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the generic linux-x86_64 key", (m) => (m.platforms["linux-x86_64"] = m.platforms["linux-x86_64-deb"])],
|
||||
["a version that is not the tag's", (m) => (m.version = "0.18.0")],
|
||||
])("stops before any request on %s", (_, mutate) => {
|
||||
const manifest = builtManifest();
|
||||
mutate(manifest);
|
||||
|
||||
const result = publish(manifest);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toContain("failed scripts/check-latest-json.jq, registry left as is");
|
||||
expect(curlCalls()).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an empty file", () => ""],
|
||||
["two documents, the last one valid", () => `{"version": "0.0.0"}\n${JSON.stringify(builtManifest())}\n`],
|
||||
])("stops before any request on %s", (_, manifest) => {
|
||||
const result = publish(manifest());
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toContain("failed scripts/check-latest-json.jq, registry left as is");
|
||||
expect(curlCalls()).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
34
scripts/check-latest-json.jq
Normal file
34
scripts/check-latest-json.jq
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Check of latest.json, the updater manifest, before release.yml deletes the
|
||||
# published one from the package registry (#320). Once that version is
|
||||
# deleted, installed apps find no update until a valid one is uploaded, so a
|
||||
# manifest that fails this check stops the job and leaves the registry as is.
|
||||
#
|
||||
# Run as: jq -e -s --arg version <X.Y.Z> -f scripts/check-latest-json.jq latest.json
|
||||
# -e turns false, null and any jq error (missing field, wrong type, invalid
|
||||
# JSON) into a non-zero exit status: the check fails closed.
|
||||
# -s reads the whole file as one array of documents, and the filter wants
|
||||
# exactly one (#419). Without it, jq 1.6 runs the filter once per document: an
|
||||
# empty file gives no output, which -e lets through with status 0, and two
|
||||
# documents are judged on the last one alone. jq 1.7 refuses the empty file
|
||||
# (status 4) but still passes the two documents.
|
||||
#
|
||||
# The key set must stay the one scripts/build-latest-json.sh writes and
|
||||
# .claude/skills/release/scripts/verify-release.sh expects.
|
||||
# scripts/build-latest-json.test.mjs runs this file on the script's output and
|
||||
# on broken manifests, and compares the three lists, on every pull request
|
||||
# through check-frontend: a misaligned list shows before the tag, not at it.
|
||||
# It also runs the text of release.yml's Publish step, which calls this file.
|
||||
# Written for jq 1.6, the version ubuntu:22.04 ships.
|
||||
#
|
||||
# Not checked here: that each signature belongs to its own file. The script's
|
||||
# test checks it before the merge, and verify-release.sh after publication
|
||||
# (the file: field of the minisign trusted comment).
|
||||
|
||||
length == 1
|
||||
and (.[0]
|
||||
| .version == $version
|
||||
and (.platforms | keys) == ["linux-x86_64-deb", "linux-x86_64-rpm", "windows-x86_64"]
|
||||
and all(.platforms[]; (.signature | type) == "string" and (.signature | length) > 0)
|
||||
and (.platforms["linux-x86_64-deb"].url | endswith(".deb"))
|
||||
and (.platforms["linux-x86_64-rpm"].url | endswith(".rpm"))
|
||||
and (.platforms["windows-x86_64"].url | endswith(".exe")))
|
||||
393
scripts/review-levels.py
Executable file
393
scripts/review-levels.py
Executable file
|
|
@ -0,0 +1,393 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Replay the review levels of REVIEW.md on merged pull requests (#456).
|
||||
|
||||
Usage:
|
||||
python3 scripts/review-levels.py [FIRST LAST] (default: 344 452)
|
||||
python3 scripts/review-levels.py --check (no network)
|
||||
|
||||
The first form reads the Forgejo API (token in $FORGEJO_TOKEN, else
|
||||
~/.forgejo-token; read-only calls) and prints each merged PR of the range
|
||||
with its trial level (pilot document, section 4.3) and its REVIEW.md level,
|
||||
then the distributions for the 39 reference PRs and for the whole range.
|
||||
--check compares the patterns of this script with the first column of the
|
||||
Haut table of REVIEW.md and exits 1 if they differ: a PR that changes the
|
||||
Niveaux section updates this script in the same diff.
|
||||
|
||||
The content conditions of the Haut table are approximated from the diff:
|
||||
lib.rs (only generate_handler![] entries are exempt), Tauri configuration
|
||||
and manifests (only the app's version is exempt), lock files (a package
|
||||
added or removed, an origin changed), check workflows (a change that can
|
||||
turn a failing check green, read against the PR's merge base), network
|
||||
calls (reqwest in Rust, fetch to an absolute URL in TS), consent files.
|
||||
"""
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
API = "https://git.lacompagniemaximus.com/api/v1/repos/maximus/simpl-resultat"
|
||||
REVIEW_MD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "REVIEW.md")
|
||||
|
||||
# The 39 reference PRs of the pilot document, frozen by number: the listing
|
||||
# that produced them (closed PRs, limit=40, sort=recentupdate) is not stable.
|
||||
REF39 = [344, 375, 377, 378, 379, 380, 381, 382, 383, 386, 387, 388, 394, 402, 403,
|
||||
407, 412, 413, 414, 415, 416, 417, 418, 420, 428, 433, 434, 435, 436, 438,
|
||||
439, 440, 441, 442, 443, 444, 445, 448, 452]
|
||||
|
||||
TRIAL_HIGH = [
|
||||
"src-tauri/src/commands/{auth_commands,token_store,license_commands,entitlements,account_cache,backup_commands,fs_commands,export_import_commands}.rs",
|
||||
"src-tauri/src/lib.rs", "src-tauri/src/database/**", "src-tauri/tauri.conf.json",
|
||||
"src-tauri/capabilities/**", ".forgejo/workflows/**", ".cargo/**", "src-tauri/Cargo.{toml,lock}",
|
||||
"package.json", "package-lock.json",
|
||||
"src/services/{authService,licenseService,db,feedbackService,dataExportService}.ts",
|
||||
"src/services/entitlements*", "scripts/build-latest-json*",
|
||||
]
|
||||
TRIAL_LOW = ["**/*.md", "docs/**", "reports/**", "tasks/**", "public/CHANGELOG*", "LICENSE"]
|
||||
|
||||
# REVIEW.md, table Haut: rows without a condition.
|
||||
HIGH_ALWAYS = [
|
||||
"src-tauri/src/database/**",
|
||||
"src-tauri/src/commands/profile_commands.rs",
|
||||
"src-tauri/src/commands/{license_commands,entitlements,auth_commands,token_store,account_cache}.rs",
|
||||
"src/services/{licenseService,authService}.ts",
|
||||
".claude/skills/issue-license/**",
|
||||
"src-tauri/src/commands/export_import_commands.rs",
|
||||
"src-tauri/src/commands/feedback_commands.rs", "src/services/feedbackService.ts",
|
||||
"src-tauri/capabilities/**",
|
||||
".forgejo/workflows/release.yml", "scripts/build-latest-json*", "scripts/check-latest-json.jq",
|
||||
".claude/skills/release/**",
|
||||
"src-tauri/build.rs", "{vite,vitest,postcss}.config.*", ".postcssrc*", ".npmrc", "**/rust-toolchain*",
|
||||
"Cargo.{toml,lock}",
|
||||
"**/.cargo/**",
|
||||
".claude/settings.json", ".mcp.json",
|
||||
]
|
||||
CONSENT_FILES = {"src/components/balance/PriceFetchControl.tsx",
|
||||
"src/components/settings/PriceFetchConsentToggle.tsx"}
|
||||
TAURI_CONFIG = ["src-tauri/{tauri*.conf.json*,Tauri*.toml}"]
|
||||
CHECK_WORKFLOWS = {".forgejo/workflows/check-rust.yml", ".forgejo/workflows/check-frontend.yml"}
|
||||
SHIPPED = ["src/**", "src-tauri/src/**", "index.html", "public/**"]
|
||||
LOW = ["**/*.md", "docs/**", "LICENSE", "tasks/**", "reports/**"]
|
||||
NEVER_LOW = ["CLAUDE.md", "REVIEW.md", "docs/adr/**", "docs/architecture.md", "docs/qa-update-cycle.md", ".claude/**"]
|
||||
|
||||
|
||||
def token():
|
||||
return os.environ.get("FORGEJO_TOKEN") or open(os.path.expanduser("~/.forgejo-token")).read().strip()
|
||||
|
||||
|
||||
def get(path, raw=False):
|
||||
req = urllib.request.Request(API + path, headers={"Authorization": "token " + token()})
|
||||
data = urllib.request.urlopen(req).read()
|
||||
return data if raw else json.loads(data)
|
||||
|
||||
|
||||
def expand_braces(pat):
|
||||
m = re.search(r"\{([^{}]*)\}", pat)
|
||||
if not m:
|
||||
return [pat]
|
||||
return list(itertools.chain.from_iterable(
|
||||
expand_braces(pat[:m.start()] + alt + pat[m.end():]) for alt in m.group(1).split(",")))
|
||||
|
||||
|
||||
def glob_re(pat):
|
||||
# '**/' spans zero or more directories, '*' and '?' never cross '/'
|
||||
i, out = 0, ""
|
||||
while i < len(pat):
|
||||
if pat.startswith("**/", i):
|
||||
out += "(?:.*/)?"; i += 3; continue
|
||||
if pat.startswith("**", i):
|
||||
out += ".*"; i += 2; continue
|
||||
c = pat[i]
|
||||
out += "[^/]*" if c == "*" else "[^/]" if c == "?" else re.escape(c)
|
||||
i += 1
|
||||
return re.compile("^" + out + "$")
|
||||
|
||||
|
||||
def match(pats, path):
|
||||
# '{a,b}' alternates, wildcards allowed inside
|
||||
return any(glob_re(p).match(path) for pat in pats for p in expand_braces(pat))
|
||||
|
||||
|
||||
def file_blocks(diff):
|
||||
out = {}
|
||||
for block in re.split(r"(?m)^diff --git ", diff)[1:]:
|
||||
out[block.split("\n", 1)[0].split(" b/")[-1]] = block
|
||||
return out
|
||||
|
||||
|
||||
def meaningful(text):
|
||||
t = text.strip()
|
||||
return t != "" and not t.startswith(("#", "//"))
|
||||
|
||||
|
||||
def changed(block):
|
||||
return [l for l in block.split("\n")
|
||||
if l[:1] in "+-" and not l.startswith(("+++", "---")) and meaningful(l[1:])]
|
||||
|
||||
|
||||
def lock_adds_or_removes(name, block):
|
||||
cargo = name.endswith("Cargo.lock")
|
||||
lines = block.split("\n")
|
||||
if cargo and any(re.match(r'^[+-]name = ', l) for l in lines):
|
||||
return True
|
||||
if not cargo and any(re.match(r'^[+-]\s+"node_modules/[^"]+": \{', l) for l in lines):
|
||||
return True
|
||||
source = r'^([+-])source = "([^"]+)"' if cargo else r'^([+-])\s+"resolved": "([^"]+)"'
|
||||
digest = r'^\+(checksum) = ' if cargo else r'^\+\s+"(integrity)": '
|
||||
version = r'^\+version = ' if cargo else r'^\+\s+"version": '
|
||||
for hunk in re.split(r"(?m)^@@", block)[1:]:
|
||||
hl = hunk.split("\n")
|
||||
# an origin whose host or scheme changes, with or without a new version
|
||||
before, after = set(), set()
|
||||
for l in hl:
|
||||
m = re.match(source, l)
|
||||
if m:
|
||||
(before if m.group(1) == "-" else after).add(origin_of(m.group(2)))
|
||||
if after - before:
|
||||
return True
|
||||
# a checksum or integrity that changes without a new version
|
||||
if any(re.match(digest, l) for l in hl) and not any(re.match(version, l) for l in hl):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def origin_of(url):
|
||||
m = re.match(r"^([a-z+]+)://([^/]+)", url)
|
||||
return m.group(1) + "://" + m.group(2) if m else url
|
||||
|
||||
|
||||
STEP = re.compile(r"^\s*- (name|uses):")
|
||||
VERDICT = re.compile(r"npm ci|npm run build|npm test|cargo check|cargo test|cargo audit|cargo-audit|Verify", re.I)
|
||||
|
||||
|
||||
def regions(base):
|
||||
lines = base.split("\n")
|
||||
region, top, job_key = {}, None, None
|
||||
for i, l in enumerate(lines, 1):
|
||||
m = re.match(r"^([A-Za-z_][\w-]*):", l)
|
||||
if m:
|
||||
top = m.group(1)
|
||||
m = re.match(r"^ ([A-Za-z_][\w-]*):", l)
|
||||
if m:
|
||||
job_key = m.group(1)
|
||||
elif re.match(r"^\S", l) or re.match(r"^ \S", l):
|
||||
job_key = None
|
||||
if top in ("on", "true"):
|
||||
region[i] = "on"
|
||||
elif top == "permissions" or job_key in ("env", "container", "permissions"):
|
||||
region[i] = "jobcfg"
|
||||
else:
|
||||
region[i] = "other"
|
||||
starts = [i for i, l in enumerate(lines, 1) if STEP.match(l)]
|
||||
for k, s in enumerate(starts):
|
||||
e = starts[k + 1] - 1 if k + 1 < len(starts) else len(lines)
|
||||
if VERDICT.search("\n".join(lines[s - 1:e])):
|
||||
for i in range(s, e + 1):
|
||||
if region[i] == "other":
|
||||
region[i] = "verdict"
|
||||
return region, lines
|
||||
|
||||
|
||||
def weakening(block, base):
|
||||
"""Ways a check-workflow diff could turn a failing check green (empty = none)."""
|
||||
region, lines = regions(base)
|
||||
reasons, old, plus = set(), 0, []
|
||||
|
||||
def flush(pos):
|
||||
texts = [p[1:] for p in plus]
|
||||
active = [t for t in texts if meaningful(t)] # comments never count
|
||||
if any(re.search(r"continue-on-error|\|\|\s*true", t) for t in active):
|
||||
reasons.add("continue-on-error or || true added")
|
||||
if any(re.search(r"GITHUB_ENV|GITHUB_PATH|npm pkg set|git checkout|git restore", t) for t in active):
|
||||
reasons.add("writes the job environment or the repository")
|
||||
if any(re.search(r"^\s*(- )?uses:", t) and not re.search(r"Swatinem/rust-cache|actions/cache", t) for t in active):
|
||||
reasons.add("action added (not a cache)")
|
||||
if any(re.search(r"rustup|toolchain|\bPATH\b|/bin/", t) for t in active):
|
||||
reasons.add("install step changes the toolchain or the PATH")
|
||||
if any(re.match(r"^(\s{4})?(if|defaults):", t) for t in active):
|
||||
reasons.add("job-level if/defaults changed")
|
||||
first = next((t for t in texts if meaningful(t)), None)
|
||||
if first is None or STEP.match(first):
|
||||
return
|
||||
prev, nxt = region.get(pos - 1, "other"), region.get(pos, "other")
|
||||
if "on" in (prev, nxt):
|
||||
if any(re.search(r"branches|paths-ignore", t) for t in active):
|
||||
reasons.add("trigger narrowed")
|
||||
j = pos - 1
|
||||
while j > 0 and not re.match(r"^\s*[\w-]+:", lines[j - 1]):
|
||||
j -= 1
|
||||
if j > 0 and "paths-ignore" in lines[j - 1] and any(re.match(r"^\s*- ", t) for t in active):
|
||||
reasons.add("paths-ignore entry added")
|
||||
elif prev == nxt == "verdict":
|
||||
reasons.add("verdict step modified")
|
||||
elif "jobcfg" in (prev, nxt):
|
||||
reasons.add("job env/container/permissions modified")
|
||||
|
||||
for line in block.split("\n"):
|
||||
m = re.match(r"^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@", line)
|
||||
if m:
|
||||
flush(old); plus = []
|
||||
old = int(m.group(1)); continue
|
||||
if line.startswith("+") and not line.startswith("+++"):
|
||||
plus.append(line); continue
|
||||
flush(old); plus = []
|
||||
if line.startswith("-") and not line.startswith("---"):
|
||||
if meaningful(line[1:]):
|
||||
if re.match(r"^(\s{4})?(if|defaults):", line[1:]):
|
||||
reasons.add("job-level if/defaults changed")
|
||||
if re.search(r"rustup|toolchain|\bPATH\b|/bin/", line[1:]):
|
||||
reasons.add("install step changes the toolchain or the PATH")
|
||||
r = region.get(old, "other")
|
||||
if r == "on":
|
||||
reasons.add("trigger removed or changed")
|
||||
elif r == "verdict":
|
||||
reasons.add("verdict step modified or removed")
|
||||
elif r == "jobcfg":
|
||||
reasons.add("job env/container/permissions modified")
|
||||
old += 1
|
||||
elif line.startswith(" "):
|
||||
old += 1
|
||||
flush(old)
|
||||
return sorted(reasons)
|
||||
|
||||
|
||||
def adds_network_call(path, block):
|
||||
if not match(SHIPPED, path) or re.search(r"test", path):
|
||||
return False
|
||||
for l in block.split("\n"):
|
||||
if l.startswith("+") and not l.startswith("+++"):
|
||||
if path.endswith(".rs") and "reqwest" in l:
|
||||
return True
|
||||
if re.search(r"\.(ts|tsx)$", path) and re.search(r"fetch\(\s*[`'\"]https?://", l):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def trial_level(paths):
|
||||
if any(match(TRIAL_HIGH, p) for p in paths):
|
||||
return "haut"
|
||||
return "bas" if all(match(TRIAL_LOW, p) for p in paths) else "normal"
|
||||
|
||||
|
||||
def review_level(number, files, diff, merge_base):
|
||||
blocks = file_blocks(diff)
|
||||
triggers = []
|
||||
for f in files:
|
||||
b = blocks.get(f["filename"], "")
|
||||
for path in filter(None, {f["filename"], f.get("previous_filename")}):
|
||||
if match(HIGH_ALWAYS, path):
|
||||
triggers.append(path)
|
||||
elif path == "src-tauri/src/lib.rs":
|
||||
if [l for l in changed(b) if not re.match(r"^[+-]\s*commands::\w+,?\s*$", l)]:
|
||||
triggers.append(path)
|
||||
elif path == "src-tauri/src/commands/balance_commands.rs":
|
||||
hunks = re.split(r"(?m)^(?=@@)", b)[1:]
|
||||
if any(re.search(r"fetch_price|activation", h.split("\n")[0]) or
|
||||
any(re.search(r"fetch_price|activation", l) for l in changed(h)) for h in hunks):
|
||||
triggers.append(path)
|
||||
elif match(TAURI_CONFIG, path) or path in ("src-tauri/Cargo.toml", "package.json"):
|
||||
if [l for l in changed(b) if not re.match(r'^[+-]\s*"?version"?\s*[:=]', l)]:
|
||||
triggers.append(path)
|
||||
elif path in ("src-tauri/Cargo.lock", "package-lock.json"):
|
||||
if lock_adds_or_removes(path, b):
|
||||
triggers.append(path)
|
||||
elif path == ".forgejo/workflows/audit.yml" and changed(b):
|
||||
triggers.append(path)
|
||||
elif path in CHECK_WORKFLOWS:
|
||||
wf = path.split("/")[-1]
|
||||
base = get(f"/raw/.forgejo/workflows/{wf}?ref={merge_base}", raw=True).decode()
|
||||
if weakening(b, base):
|
||||
triggers.append(path)
|
||||
elif path.startswith(".forgejo/workflows/") and f["status"] == "added":
|
||||
triggers.append(path + " (new workflow)")
|
||||
elif path == "REVIEW.md":
|
||||
triggers.append(path + " (check the « Niveaux » section by hand)")
|
||||
if adds_network_call(path, b):
|
||||
triggers.append(path + " (network call)")
|
||||
added = [l for l in b.split("\n") if l.startswith("+") and not l.startswith("+++")]
|
||||
if path.startswith(".forgejo/workflows/") and any(re.search(r"\bsecrets\b|pull_request_target", l) for l in added):
|
||||
triggers.append(path + " (secrets or pull_request_target added)")
|
||||
if path in CONSENT_FILES and any(re.search(r"consent", l, re.I) for l in changed(b)):
|
||||
triggers.append(path + " (consent)")
|
||||
parts = path.split("/")
|
||||
top_level = len(parts) == 1 or (parts[0] == "src-tauri" and len(parts) == 2)
|
||||
new_hidden = f["status"] == "added" and any(
|
||||
x.startswith(".") for x in parts[:-1]) and parts[0] not in (".forgejo", ".claude", ".cargo", ".github")
|
||||
# Forgejo reports an edited file as "changed"
|
||||
if (f["status"] not in ("changed", "modified") and top_level and not path.endswith(".md")) or new_hidden:
|
||||
triggers.append(path + " (" + f["status"] + " at top level)")
|
||||
paths = [f["filename"] for f in files]
|
||||
if triggers:
|
||||
return "haut", sorted(set(triggers))
|
||||
if all(match(LOW, p) and not match(NEVER_LOW, p) for p in paths):
|
||||
return "bas", []
|
||||
return "normal", []
|
||||
|
||||
|
||||
CONDITIONAL = {
|
||||
"src-tauri/src/lib.rs", "src-tauri/src/commands/balance_commands.rs",
|
||||
"src-tauri/Cargo.toml", "package.json", "src-tauri/Cargo.lock", "package-lock.json",
|
||||
".forgejo/workflows/**", ".forgejo/workflows/audit.yml",
|
||||
".forgejo/workflows/{check-rust,check-frontend}.yml", "REVIEW.md",
|
||||
}
|
||||
|
||||
|
||||
def check():
|
||||
"""Compare this script's patterns with the first column of the Haut table."""
|
||||
text = open(REVIEW_MD, encoding="utf-8").read()
|
||||
table = text.split("### Haut", 1)[1].split("\n\n", 2)[1]
|
||||
documented = set()
|
||||
for row in table.split("\n"):
|
||||
if not row.startswith("| ") or row.startswith("| Motif") or row.startswith("| fichier à la racine"):
|
||||
continue
|
||||
documented.update(re.findall(r"`([^`]+)`", row.split(" | ")[0]))
|
||||
coded = set(HIGH_ALWAYS) | set(TAURI_CONFIG) | set(SHIPPED) | CONDITIONAL
|
||||
for label, extra in (("REVIEW.md only", documented - coded), ("script only", coded - documented)):
|
||||
for pattern in sorted(extra):
|
||||
print(f"{label}: {pattern}")
|
||||
if documented != coded:
|
||||
return 1
|
||||
print(f"ok: {len(coded)} patterns, identical to the Haut table of REVIEW.md")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
if sys.argv[1:] == ["--check"]:
|
||||
sys.exit(check())
|
||||
first, last = (int(sys.argv[1]), int(sys.argv[2])) if len(sys.argv) == 3 else (344, 452)
|
||||
merged, page = [], 1
|
||||
while True:
|
||||
batch = get(f"/pulls?state=closed&limit=50&page={page}")
|
||||
if not batch:
|
||||
break
|
||||
merged += [p for p in batch if p.get("merged") and first <= p["number"] <= last]
|
||||
page += 1
|
||||
merged.sort(key=lambda p: p["number"])
|
||||
rows = []
|
||||
for p in merged:
|
||||
n = p["number"]
|
||||
files, fpage = [], 1
|
||||
while True:
|
||||
# the instance caps a page at 50 items: read until an empty page
|
||||
fb = get(f"/pulls/{n}/files?limit=50&page={fpage}")
|
||||
if not fb:
|
||||
break
|
||||
files += fb
|
||||
fpage += 1
|
||||
diff = get(f"/pulls/{n}.diff", raw=True).decode("utf-8", "replace")
|
||||
merge_base = get(f"/pulls/{n}")["merge_base"]
|
||||
trial = trial_level([f["filename"] for f in files])
|
||||
level, triggers = review_level(n, files, diff, merge_base)
|
||||
rows.append((n, trial, level, triggers))
|
||||
print(f"#{n}\t{'ref' if n in REF39 else ' '}\t{trial}\t{level}\t{'; '.join(triggers)}")
|
||||
for label, keep in (("39 reference PRs", lambda n: n in REF39), (f"{len(rows)} PRs #{first}-#{last}", lambda n: True)):
|
||||
sel = [r for r in rows if keep(r[0])]
|
||||
for col, name in ((1, "trial"), (2, "REVIEW.md")):
|
||||
counts = {lvl: sum(1 for r in sel if r[col] == lvl) for lvl in ("bas", "normal", "haut")}
|
||||
share = 100 * counts["haut"] / len(sel)
|
||||
print(f"{label}, {name}: {counts} -> haut {share:.1f} %")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
43
src-tauri/Cargo.lock
generated
43
src-tauri/Cargo.lock
generated
|
|
@ -100,15 +100,6 @@ version = "1.0.101"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
|
|
@ -935,17 +926,6 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
|
|
@ -4049,9 +4029,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.36"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
|
|
@ -4111,9 +4091,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
|||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
|
|
@ -4509,7 +4489,7 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
|||
|
||||
[[package]]
|
||||
name = "simpl-result"
|
||||
version = "0.15.0"
|
||||
version = "0.17.2"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
|
|
@ -5363,7 +5343,6 @@ dependencies = [
|
|||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -7183,18 +7162,6 @@ dependencies = [
|
|||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.13.0",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.19"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "simpl-result"
|
||||
version = "0.15.0"
|
||||
version = "0.17.2"
|
||||
description = "Personal finance management app"
|
||||
license = "GPL-3.0-only"
|
||||
authors = ["you"]
|
||||
|
|
@ -23,7 +23,12 @@ tauri = { version = "2", features = [] }
|
|||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
# Built without its default `zip` feature (#455): that feature only pulls
|
||||
# `zip` on Windows and `tar` on Linux to unpack archived update packages, and
|
||||
# ours are never archives (`createUpdaterArtifacts: true` serves the NSIS
|
||||
# `.exe`, the `.deb` and the `.rpm` as they are). Restore it only if
|
||||
# `createUpdaterArtifacts` becomes "v1Compatible" (docs/qa-update-cycle.md).
|
||||
tauri-plugin-updater = { version = "2", default-features = false, features = ["rustls-tls"] }
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||
|
|
@ -66,6 +71,9 @@ hmac = "0.12"
|
|||
ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] }
|
||||
# HTTP mock server for balance_commands fetch_price tests (Issue #155).
|
||||
mockito = "1.6"
|
||||
# MockRuntime for the profile database folder test (#390). The `test`
|
||||
# feature adds no crate (`test = []` in tauri's manifest).
|
||||
tauri = { version = "2", features = ["test"] }
|
||||
|
||||
[features]
|
||||
# Dev-only escape hatch: when enabled, the SR_DEV_EDITION env var forces the
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@
|
|||
//! `Authorization`, `Accept`, and `User-Agent` headers.
|
||||
//!
|
||||
//! Database access pattern:
|
||||
//! - All reads use `rusqlite::Connection::open(app_data_dir / db_filename)`,
|
||||
//! matching the existing `repair_migrations` helper in `profile_commands.rs`.
|
||||
//! - All reads open the file `profile_db_path` resolves (`profile_commands.rs`):
|
||||
//! the folder where tauri-plugin-sql keeps the databases, which is NOT
|
||||
//! `app_data_dir` on Linux (#390).
|
||||
//! - The frontend passes `db_filename` (the active profile DB), exactly
|
||||
//! like it does for `repair_migrations` and `delete_profile_db`. Keeps
|
||||
//! the active-profile resolution where it already lives (in TS) and
|
||||
|
|
@ -22,8 +23,10 @@
|
|||
use chrono::NaiveDate;
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::commands::license_commands::{remove_activation_token_if_unchanged_in, ACTIVATION_FILE};
|
||||
use crate::commands::return_calculator::{modified_dietz, AccountReturn};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -54,6 +57,12 @@ pub struct PriceResponse {
|
|||
pub enum FetchPriceError {
|
||||
Auth,
|
||||
PremiumRequired,
|
||||
/// 403 `license_revoked`: the license the activation token stands for was revoked.
|
||||
/// Named for the user, never recorded as the state of the stored key: the token names
|
||||
/// a license the client cannot tie to that key, and a data directory written by 0.15.0
|
||||
/// or earlier can hold a valid key next to the token of a previous license (#365). The
|
||||
/// refused token is dropped instead (#367, [`fetch_price_in`]).
|
||||
LicenseRevoked,
|
||||
SymbolNotFound,
|
||||
RateLimit { retry_after_s: u64 },
|
||||
ProviderUnavailable,
|
||||
|
|
@ -66,6 +75,7 @@ impl std::fmt::Display for FetchPriceError {
|
|||
match self {
|
||||
FetchPriceError::Auth => write!(f, "auth"),
|
||||
FetchPriceError::PremiumRequired => write!(f, "premium_required"),
|
||||
FetchPriceError::LicenseRevoked => write!(f, "license_revoked"),
|
||||
FetchPriceError::SymbolNotFound => write!(f, "symbol_not_found"),
|
||||
FetchPriceError::RateLimit { retry_after_s } => {
|
||||
write!(f, "rate_limit (retry after {}s)", retry_after_s)
|
||||
|
|
@ -91,34 +101,43 @@ fn base_url() -> String {
|
|||
.unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
|
||||
}
|
||||
|
||||
/// Read the stored activation token from disk (raw JWT string).
|
||||
/// Returns `Err(FetchPriceError::Auth)` when the file is absent or unreadable.
|
||||
fn read_stored_activation_token(app: &tauri::AppHandle) -> Result<String, FetchPriceError> {
|
||||
let app_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|_| FetchPriceError::Auth)?;
|
||||
let token_path = app_dir.join("activation.token");
|
||||
std::fs::read_to_string(&token_path)
|
||||
/// Read the activation token stored in `dir` (raw JWT string, trimmed).
|
||||
/// Returns `Err(FetchPriceError::Auth)` when the file is absent or unreadable: nothing is sent
|
||||
/// then, and the message invites the user to activate this machine.
|
||||
fn read_stored_activation_token_in(dir: &Path) -> Result<String, FetchPriceError> {
|
||||
std::fs::read_to_string(dir.join(ACTIVATION_FILE))
|
||||
.map(|s| s.trim().to_string())
|
||||
.map_err(|_| FetchPriceError::Auth)
|
||||
}
|
||||
|
||||
/// Core implementation — separated from the Tauri command so tests can inject
|
||||
/// an arbitrary token string and base URL without touching the file system.
|
||||
/// The price call as the command performs it, without `AppHandle` so tests run it in a
|
||||
/// temporary directory and against a mock server: the stored activation token is read, then
|
||||
/// sent to `api_base`.
|
||||
///
|
||||
/// Design note (MEDIUM decision in decisions-log.md): the public `fetch_price`
|
||||
/// command is a thin wrapper that loads the token then delegates here. Tests
|
||||
/// call this inner function directly with an explicit `api_base` to avoid
|
||||
/// env-var races between concurrent test threads.
|
||||
async fn fetch_price_with_token(
|
||||
token: &str,
|
||||
/// A 403 `license_revoked` condemns that token (#367): the license it was issued for is
|
||||
/// revoked, so it will never fetch a price again, whatever key is stored. It is dropped and
|
||||
/// the License card offers "Activate" again, but only while the file still holds it: an
|
||||
/// activation that lands while the refused request is in flight writes a new token, which
|
||||
/// stays (CWE-367). Best effort: the refusal reaches the user whatever happens to the file.
|
||||
/// The revoked state of the stored key is never written from here: this route judges the
|
||||
/// license of the token, which the client cannot tie to the key (#365).
|
||||
pub(crate) async fn fetch_price_in(
|
||||
dir: &Path,
|
||||
symbol: &str,
|
||||
date: &str,
|
||||
api_base: &str,
|
||||
) -> Result<PriceResponse, FetchPriceError> {
|
||||
fetch_price_inner(token, symbol, date, &base_url()).await
|
||||
let token = read_stored_activation_token_in(dir)?;
|
||||
let result = fetch_price_inner(&token, symbol, date, api_base).await;
|
||||
if matches!(result, Err(FetchPriceError::LicenseRevoked)) {
|
||||
let _ = remove_activation_token_if_unchanged_in(dir, &token);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The HTTP call alone, separated so tests can inject an arbitrary token string and base URL
|
||||
/// without touching the file system. Tests pass an explicit `api_base` to avoid env-var races
|
||||
/// between concurrent test threads.
|
||||
async fn fetch_price_inner(
|
||||
token: &str,
|
||||
symbol: &str,
|
||||
|
|
@ -162,7 +181,19 @@ async fn fetch_price_inner(
|
|||
Ok(price_resp)
|
||||
}
|
||||
401 => Err(FetchPriceError::Auth),
|
||||
403 => Err(FetchPriceError::PremiumRequired),
|
||||
403 => {
|
||||
// The price route refuses for two reasons (api-contract-prices.md §5): a
|
||||
// revoked license, tested first by the server, or an edition below Premium.
|
||||
// Only `error.code` tells them apart; an unreadable body keeps the old reading.
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
match body.pointer("/error/code").and_then(|v| v.as_str()) {
|
||||
Some("license_revoked") => Err(FetchPriceError::LicenseRevoked),
|
||||
_ => Err(FetchPriceError::PremiumRequired),
|
||||
}
|
||||
}
|
||||
404 => Err(FetchPriceError::SymbolNotFound),
|
||||
429 => {
|
||||
// Parse `error.retry_after` from the JSON body.
|
||||
|
|
@ -183,9 +214,10 @@ async fn fetch_price_inner(
|
|||
|
||||
/// Fetch the price of `symbol` on `date` (ISO `YYYY-MM-DD`) via maximus-api.
|
||||
///
|
||||
/// Reads the stored activation token, then calls `GET /v1/prices`. Returns a
|
||||
/// serialized `FetchPriceError` JSON string on error so the JS layer can
|
||||
/// `JSON.parse` and branch on `code`.
|
||||
/// Reads the stored activation token, then calls `GET /v1/prices`; a token refused as issued
|
||||
/// for a revoked license is dropped ([`fetch_price_in`], #367). Returns a serialized
|
||||
/// `FetchPriceError` JSON string on error so the JS layer can `JSON.parse` and branch on
|
||||
/// `code`.
|
||||
///
|
||||
/// Privacy contract (§3.2): only `Authorization`, `Accept`, and `User-Agent`
|
||||
/// are sent. `User-Agent` is set on the reqwest client builder — not injected
|
||||
|
|
@ -196,8 +228,11 @@ pub async fn fetch_price(
|
|||
symbol: String,
|
||||
date: String,
|
||||
) -> Result<PriceResponse, String> {
|
||||
let token = read_stored_activation_token(&app).map_err(|e| price_error_to_string(&e))?;
|
||||
fetch_price_with_token(&token, &symbol, &date)
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|_| price_error_to_string(&FetchPriceError::Auth))?;
|
||||
fetch_price_in(&dir, &symbol, &date, &base_url())
|
||||
.await
|
||||
.map_err(|e| price_error_to_string(&e))
|
||||
}
|
||||
|
|
@ -224,16 +259,10 @@ pub fn compute_account_return(
|
|||
let start_date = parse_iso_date(&period_start, "period_start")?;
|
||||
let end_date = parse_iso_date(&period_end, "period_end")?;
|
||||
|
||||
let app_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("Cannot get app data dir: {}", e))?;
|
||||
let db_path = app_dir.join(&db_filename);
|
||||
let db_path = super::profile_commands::profile_db_path(&app, &db_filename)?;
|
||||
if !db_path.exists() {
|
||||
return Err(format!(
|
||||
"Profile database not found: {}",
|
||||
db_path.display()
|
||||
));
|
||||
// The file name only: the folder holds the OS user name.
|
||||
return Err(format!("Profile database not found: {}", db_filename));
|
||||
}
|
||||
|
||||
let conn = Connection::open(&db_path)
|
||||
|
|
@ -367,7 +396,8 @@ fn read_cash_flows(
|
|||
// Each test calls `fetch_price_inner` directly, passing the mock server URL
|
||||
// as `api_base`. This avoids env-var races between concurrent test threads
|
||||
// (all tokio tests share the same process) and bypasses the file-system
|
||||
// activation token loading.
|
||||
// activation token loading. The tests of the stored token (#367) call
|
||||
// `fetch_price_in` instead, with a temporary data directory.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -428,6 +458,49 @@ mod tests {
|
|||
assert_eq!(parsed["code"], "premium_required");
|
||||
}
|
||||
|
||||
/// #365: a revoked license used to read as "premium required", because every 403 did.
|
||||
#[tokio::test]
|
||||
async fn it_names_a_revoked_license_on_403() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(r#"{"error":{"code":"license_revoked","message":"License has been revoked"}}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = fetch_price_inner("revoked-token", "AAPL", "2026-04-25", &server.url()).await;
|
||||
let err_str = price_error_to_string(&result.unwrap_err());
|
||||
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
|
||||
assert_eq!(parsed, serde_json::json!({ "code": "license_revoked" }));
|
||||
}
|
||||
|
||||
/// A 403 whose body cannot be read, or whose code is not known, keeps the old reading.
|
||||
#[tokio::test]
|
||||
async fn it_keeps_premium_required_for_any_other_403() {
|
||||
let bodies = [
|
||||
("text/html", "<html>Forbidden</html>"),
|
||||
("application/json", r#"{"error":{"code":"something_else","message":"x"}}"#),
|
||||
("application/json", r#"{"code":"license_revoked"}"#),
|
||||
];
|
||||
for (content_type, body) in bodies {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||||
.with_status(403)
|
||||
.with_header("content-type", content_type)
|
||||
.with_body(body)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = fetch_price_inner("tok", "AAPL", "2026-04-25", &server.url()).await;
|
||||
let err_str = price_error_to_string(&result.unwrap_err());
|
||||
let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap();
|
||||
assert_eq!(parsed["code"], "premium_required", "body {}", body);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn it_returns_symbol_not_found_on_404() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
|
|
@ -529,4 +602,172 @@ mod tests {
|
|||
// JSON parse would fail. A successful 200 parse confirms the privacy contract.
|
||||
assert_eq!(result.unwrap().symbol, "BTC");
|
||||
}
|
||||
|
||||
// === The stored token, and a token refused as issued for a revoked license (#367) ==========
|
||||
|
||||
/// A data directory of its own for each test (test name + pid: cargo runs tests on parallel
|
||||
/// threads), removed when the test ends, failed assertion included.
|
||||
struct TestDir(std::path::PathBuf);
|
||||
|
||||
impl TestDir {
|
||||
fn new(name: &str) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"simpl-resultat-price-test-{}-{}",
|
||||
name,
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
fn write_token(&self, contents: &str) {
|
||||
std::fs::write(self.0.join(ACTIVATION_FILE), contents).unwrap();
|
||||
}
|
||||
|
||||
fn token(&self) -> Option<String> {
|
||||
std::fs::read_to_string(self.0.join(ACTIVATION_FILE)).ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
const REVOKED: &str = r#"{"error":{"code":"license_revoked","message":"License has been revoked"}}"#;
|
||||
|
||||
/// `ok`, or the code the front reads from the error.
|
||||
fn outcome(result: &Result<PriceResponse, FetchPriceError>) -> String {
|
||||
match result {
|
||||
Ok(_) => "ok".to_string(),
|
||||
Err(e) => serde_json::from_str::<serde_json::Value>(&price_error_to_string(e)).unwrap()
|
||||
["code"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_revoked_license_drops_the_token_it_refused() {
|
||||
// The license of this token is revoked: it will never fetch a price again, and the card
|
||||
// must offer "Activate". Written with a newline, as an older version may have left it,
|
||||
// read from `ACTIVATION_FILE` and sent trimmed. Nothing is written in its place: the
|
||||
// key's state is not this route's to record (#365).
|
||||
let dir = TestDir::new("drops_revoked_token");
|
||||
dir.write_token("token-of-a-revoked-license\n");
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let route = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||||
.match_header("authorization", "Bearer token-of-a-revoked-license")
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(REVOKED)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", &server.url()).await;
|
||||
|
||||
assert_eq!(outcome(&result), "license_revoked");
|
||||
route.assert_async().await;
|
||||
assert_eq!(std::fs::read_dir(&dir.0).unwrap().count(), 0, "the token gone, nothing written");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_token_written_while_the_refused_request_was_in_flight_stays() {
|
||||
// An activation lands while the refused request is in flight (CWE-367): the refusal is
|
||||
// about the token that was sent, not the one written meanwhile.
|
||||
let dir = TestDir::new("token_replaced_in_flight");
|
||||
dir.write_token("refused-token");
|
||||
let token_path = dir.0.join(ACTIVATION_FILE);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let route = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||||
.match_request(move |_request| {
|
||||
std::fs::write(&token_path, "fresh-token").unwrap();
|
||||
true
|
||||
})
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(REVOKED)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", &server.url()).await;
|
||||
|
||||
assert_eq!(outcome(&result), "license_revoked");
|
||||
route.assert_async().await;
|
||||
assert_eq!(dir.token().as_deref(), Some("fresh-token"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_other_answer_drops_the_token() {
|
||||
// Only a revoked license condemns the token: a Base license, a token the server does
|
||||
// not accept (the user is asked to activate), a missing symbol and every outage leave
|
||||
// it as it was.
|
||||
let rows: Vec<(usize, &str, &str)> = vec![
|
||||
(
|
||||
200,
|
||||
r#"{"symbol":"AAPL","date":"2026-04-25","actual_date":null,"price":173.45,"currency":"USD","source":"yahoo","fetched_at":"2026-04-25T14:32:11Z","cached":false}"#,
|
||||
"ok",
|
||||
),
|
||||
(401, r#"{"error":{"code":"invalid_token","message":"x"}}"#, "auth"),
|
||||
(403, r#"{"error":{"code":"premium_required","message":"x"}}"#, "premium_required"),
|
||||
(403, "<html>Forbidden</html>", "premium_required"),
|
||||
(404, r#"{"error":{"code":"symbol_not_found","message":"x"}}"#, "symbol_not_found"),
|
||||
(
|
||||
429,
|
||||
r#"{"error":{"code":"rate_limit_exceeded","message":"x","retry_after":42}}"#,
|
||||
"rate_limit",
|
||||
),
|
||||
(502, r#"{"error":{"code":"provider_unavailable","message":"x"}}"#, "provider_unavailable"),
|
||||
];
|
||||
for (status, body, expected) in rows {
|
||||
let dir = TestDir::new("keeps_token");
|
||||
dir.write_token("token");
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let route = server
|
||||
.mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string()))
|
||||
.with_status(status)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(body)
|
||||
.expect(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", &server.url()).await;
|
||||
|
||||
assert_eq!(outcome(&result), expected, "{} {}", status, body);
|
||||
route.assert_async().await;
|
||||
assert_eq!(dir.token().as_deref(), Some("token"), "{} {}", status, body);
|
||||
}
|
||||
|
||||
// Offline. Port 1 on loopback: nothing listens.
|
||||
let dir = TestDir::new("keeps_token_offline");
|
||||
dir.write_token("token");
|
||||
let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", "http://127.0.0.1:1").await;
|
||||
assert_eq!(outcome(&result), "network");
|
||||
assert_eq!(dir.token().as_deref(), Some("token"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn without_a_token_nothing_is_sent_and_the_answer_is_auth() {
|
||||
// The rest of a batch once the token is gone: the message invites to activate.
|
||||
let dir = TestDir::new("no_token");
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let any_request = server
|
||||
.mock("GET", mockito::Matcher::Any)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let result = fetch_price_in(&dir.0, "AAPL", "2026-04-25", &server.url()).await;
|
||||
|
||||
assert_eq!(outcome(&result), "auth");
|
||||
any_request.assert_async().await;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,11 +32,11 @@ pub fn is_feature_allowed(feature: &str, edition: &str) -> bool {
|
|||
|
||||
/// Static matrix check OR signed per-license `features[]` override.
|
||||
///
|
||||
/// Fail-closed in Free (CWE-863): a `license.key` copied onto another machine
|
||||
/// resolves to "free" through the machine-binding path, which already drops the
|
||||
/// signed features (see `license_commands::current_entitlements`). As defense
|
||||
/// in depth we also refuse the override here whenever the edition is free, so
|
||||
/// signed features can never rescue a downgraded license.
|
||||
/// Fail-closed in Free (CWE-863): a key that no longer validates (invalid or
|
||||
/// expired) resolves to "free" with its signed features already dropped (see
|
||||
/// `license_commands::current_entitlements`). As defense in depth we also
|
||||
/// refuse the override here whenever the edition is free, so signed features
|
||||
/// can never rescue a license that resolves to free.
|
||||
pub fn is_entitled(feature: &str, edition: &str, features: &[String]) -> bool {
|
||||
if edition == EDITION_FREE {
|
||||
return is_feature_allowed(feature, edition);
|
||||
|
|
@ -45,8 +45,8 @@ pub fn is_entitled(feature: &str, edition: &str, features: &[String]) -> bool {
|
|||
}
|
||||
|
||||
/// Tauri command: is `feature` available right now? Edition AND signed
|
||||
/// per-license feature overrides are resolved through the same machine-binding
|
||||
/// path (`license_commands::current_entitlements`), then combined by
|
||||
/// per-license feature overrides are resolved together from the license key
|
||||
/// (`license_commands::current_entitlements`), then combined by
|
||||
/// [`is_entitled`].
|
||||
#[tauri::command]
|
||||
pub fn check_entitlement(app: tauri::AppHandle, feature: String) -> Result<bool, String> {
|
||||
|
|
@ -101,8 +101,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn override_ignored_in_free() {
|
||||
// CWE-863: signed features[] must never rescue a license downgraded to
|
||||
// free (copied key / machine mismatch) — not even for a feature that a
|
||||
// CWE-863: signed features[] must never rescue a license that resolves
|
||||
// to free (invalid or expired key) — not even for a feature that a
|
||||
// paid edition would get from the static matrix.
|
||||
let features = vec!["balance".to_string(), "auto-update".to_string()];
|
||||
assert!(!is_entitled("balance", EDITION_FREE, &features));
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use chrono::{Datelike, Months, NaiveDate};
|
||||
use encoding_rs::{UTF_8, WINDOWS_1252, ISO_8859_15};
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
|
@ -21,14 +22,39 @@ pub struct ScannedSource {
|
|||
pub files: Vec<ScannedFile>,
|
||||
}
|
||||
|
||||
/// Result of a scan of the import folder: the sources (one per direct
|
||||
/// subfolder) and the names of the statement files placed directly at the
|
||||
/// root, which no source reads and the Import page names in a warning (#427).
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct ImportFolderScan {
|
||||
pub sources: Vec<ScannedSource>,
|
||||
pub root_files: Vec<String>,
|
||||
}
|
||||
|
||||
/// True when the file name has a statement extension (`csv` or `txt`, any
|
||||
/// case), the same rule for the root and for the subfolders.
|
||||
fn has_statement_extension(path: &Path) -> bool {
|
||||
let ext = path
|
||||
.extension()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_lowercase();
|
||||
ext == "csv" || ext == "txt"
|
||||
}
|
||||
|
||||
/// Scans the import folder: each non-hidden direct subfolder is a source with
|
||||
/// its direct CSV/TXT files, sorted by name. CSV/TXT files placed directly at
|
||||
/// the root are read by no source; their names (non-hidden, sorted) are
|
||||
/// returned in `root_files` so the Import page can warn about them (#427).
|
||||
#[tauri::command]
|
||||
pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, String> {
|
||||
pub fn scan_import_folder(folder_path: String) -> Result<ImportFolderScan, String> {
|
||||
let root = Path::new(&folder_path);
|
||||
if !root.is_dir() {
|
||||
return Err(format!("Folder does not exist: {}", folder_path));
|
||||
}
|
||||
|
||||
let mut sources: Vec<ScannedSource> = Vec::new();
|
||||
let mut root_files: Vec<String> = Vec::new();
|
||||
|
||||
let entries = fs::read_dir(root).map_err(|e| format!("Cannot read folder: {}", e))?;
|
||||
|
||||
|
|
@ -37,6 +63,16 @@ pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, Str
|
|||
let path = entry.path();
|
||||
|
||||
if !path.is_dir() {
|
||||
if path.is_file() && has_statement_extension(&path) {
|
||||
let name = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
if !name.starts_with('.') {
|
||||
root_files.push(name);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -59,13 +95,7 @@ pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, Str
|
|||
continue;
|
||||
}
|
||||
|
||||
let ext = file_path
|
||||
.extension()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_lowercase();
|
||||
|
||||
if ext != "csv" && ext != "txt" {
|
||||
if !has_statement_extension(file_path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -104,8 +134,12 @@ pub fn scan_import_folder(folder_path: String) -> Result<Vec<ScannedSource>, Str
|
|||
}
|
||||
|
||||
sources.sort_by(|a, b| a.folder_name.cmp(&b.folder_name));
|
||||
root_files.sort();
|
||||
|
||||
Ok(sources)
|
||||
Ok(ImportFolderScan {
|
||||
sources,
|
||||
root_files,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -172,6 +206,91 @@ pub async fn pick_folder(app: tauri::AppHandle) -> Result<Option<String>, String
|
|||
Ok(folder.map(|f| f.to_string()))
|
||||
}
|
||||
|
||||
/// Folder names the onboarding samples are written under — the same in FR and
|
||||
/// EN (decided in planning), so the scan shows one source per bank whatever the
|
||||
/// UI language.
|
||||
const SAMPLE_DESJARDINS_FOLDER: &str = "Exemple Desjardins";
|
||||
const SAMPLE_TANGERINE_FOLDER: &str = "Exemple Tangerine";
|
||||
|
||||
/// Sample statements embedded at compile time. The paths are relative to
|
||||
/// `src-tauri/src/commands/`; the same files are read by vitest, so the Rust
|
||||
/// command and the detection test cannot drift apart.
|
||||
const SAMPLE_DESJARDINS_TEMPLATE: &str =
|
||||
include_str!("../../../src/__fixtures__/samples/exemple-desjardins.csv");
|
||||
const SAMPLE_TANGERINE_TEMPLATE: &str =
|
||||
include_str!("../../../src/__fixtures__/samples/exemple-tangerine.csv");
|
||||
|
||||
/// Write the two onboarding sample statements (a Desjardins chequing account and
|
||||
/// a Tangerine credit card, both synthetic) into `import_folder`, one sub-folder
|
||||
/// each, so that the very next scan of the import folder shows two sources a
|
||||
/// new user can import right away.
|
||||
///
|
||||
/// `today` is the webview's local date as `YYYY-MM-DD`; chrono is compiled
|
||||
/// without `clock`, so the command never reads the system clock and stays
|
||||
/// deterministic. The templates carry `{{M1}}` / `{{M2}}` tokens, rendered as
|
||||
/// `MM/YYYY` of the two complete months before `today` (`{{M1}}` the most
|
||||
/// recent), and each file is named `releve-YYYY-MM.csv` after `{{M1}}`.
|
||||
///
|
||||
/// Refuses to run when `import_folder` is not a directory, and when either
|
||||
/// sample folder already exists — checked BEFORE anything is written, so an
|
||||
/// existing folder is never touched. The command only ever receives the import
|
||||
/// folder and a date: the names of what it writes are fixed here. Returns the
|
||||
/// two folder NAMES (not paths).
|
||||
#[tauri::command]
|
||||
pub fn create_sample_import_folders(
|
||||
import_folder: String,
|
||||
today: String,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let today = NaiveDate::parse_from_str(&today, "%Y-%m-%d")
|
||||
.map_err(|e| format!("Invalid date '{}': {}", today, e))?;
|
||||
let first_of_month = today
|
||||
.with_day(1)
|
||||
.ok_or_else(|| "Cannot compute the first day of the month".to_string())?;
|
||||
let m1 = first_of_month
|
||||
.checked_sub_months(Months::new(1))
|
||||
.ok_or_else(|| "Cannot compute the previous month".to_string())?;
|
||||
let m2 = first_of_month
|
||||
.checked_sub_months(Months::new(2))
|
||||
.ok_or_else(|| "Cannot compute the month before the previous one".to_string())?;
|
||||
|
||||
let root = Path::new(&import_folder);
|
||||
if !root.is_dir() {
|
||||
return Err(format!("Folder does not exist: {}", import_folder));
|
||||
}
|
||||
|
||||
let samples: [(&str, &str); 2] = [
|
||||
(SAMPLE_DESJARDINS_FOLDER, SAMPLE_DESJARDINS_TEMPLATE),
|
||||
(SAMPLE_TANGERINE_FOLDER, SAMPLE_TANGERINE_TEMPLATE),
|
||||
];
|
||||
|
||||
// Both existence checks run before the first write: a user who already has
|
||||
// one of the folders keeps it untouched and gets neither.
|
||||
for (folder_name, _) in &samples {
|
||||
if root.join(folder_name).exists() {
|
||||
return Err(format!("Folder already exists: {}", folder_name));
|
||||
}
|
||||
}
|
||||
|
||||
let m1_token = m1.format("%m/%Y").to_string();
|
||||
let m2_token = m2.format("%m/%Y").to_string();
|
||||
let filename = format!("releve-{}.csv", m1.format("%Y-%m"));
|
||||
|
||||
let mut created: Vec<String> = Vec::with_capacity(samples.len());
|
||||
for (folder_name, template) in &samples {
|
||||
let folder = root.join(folder_name);
|
||||
fs::create_dir(&folder)
|
||||
.map_err(|e| format!("Cannot create folder {}: {}", folder_name, e))?;
|
||||
let content = template
|
||||
.replace("{{M1}}", &m1_token)
|
||||
.replace("{{M2}}", &m2_token);
|
||||
fs::write(folder.join(&filename), content)
|
||||
.map_err(|e| format!("Cannot write {}/{}: {}", folder_name, filename, e))?;
|
||||
created.push((*folder_name).to_string());
|
||||
}
|
||||
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
fn decode_bytes(bytes: &[u8], encoding: &str) -> Result<String, String> {
|
||||
// Strip BOM if present
|
||||
let bytes = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
|
||||
|
|
@ -207,3 +326,228 @@ fn decode_bytes(bytes: &[u8], encoding: &str) -> Result<String, String> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sample_folder_tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// One scratch directory per CASE: the cases of this binary run in parallel
|
||||
/// inside a single process, so `process::id()` alone would make them share
|
||||
/// — and trip over — the same folder.
|
||||
fn sandbox(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"simpl-resultat-samples-{}-{}",
|
||||
std::process::id(),
|
||||
name
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).expect("sandbox created");
|
||||
dir
|
||||
}
|
||||
|
||||
fn cleanup(dir: &Path) {
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
fn as_arg(path: &Path) -> String {
|
||||
path.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
/// Every `DD/MM/YYYY` date of a rendered file must fall in one of the two
|
||||
/// expected months — no token left, no third month.
|
||||
fn assert_dates_in(content: &str, months: &[&str]) {
|
||||
assert!(!content.contains("{{"), "token left in {}", content);
|
||||
for line in content.lines().skip(1) {
|
||||
let date = line.split([';', ',']).next().unwrap();
|
||||
assert_eq!(date.len(), 10, "unexpected date {}", date);
|
||||
let month_year = &date[3..];
|
||||
assert!(
|
||||
months.contains(&month_year),
|
||||
"date {} is outside {:?}",
|
||||
date,
|
||||
months
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_missing_import_folder() {
|
||||
let dir = sandbox("missing");
|
||||
let missing = dir.join("nope");
|
||||
let result = create_sample_import_folders(as_arg(&missing), "2026-09-25".to_string());
|
||||
cleanup(&dir);
|
||||
let err = result.unwrap_err();
|
||||
assert!(err.contains("does not exist"), "{}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_when_desjardins_folder_exists_and_writes_nothing() {
|
||||
let dir = sandbox("existing");
|
||||
fs::create_dir(dir.join(SAMPLE_DESJARDINS_FOLDER)).unwrap();
|
||||
let result = create_sample_import_folders(as_arg(&dir), "2026-09-25".to_string());
|
||||
let tangerine_created = dir.join(SAMPLE_TANGERINE_FOLDER).exists();
|
||||
let desjardins_empty = fs::read_dir(dir.join(SAMPLE_DESJARDINS_FOLDER))
|
||||
.unwrap()
|
||||
.next()
|
||||
.is_none();
|
||||
cleanup(&dir);
|
||||
let err = result.unwrap_err();
|
||||
assert!(err.contains(SAMPLE_DESJARDINS_FOLDER), "{}", err);
|
||||
assert!(!tangerine_created, "Exemple Tangerine must not be created");
|
||||
assert!(desjardins_empty, "the existing folder must stay untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_both_folders_for_the_two_previous_months() {
|
||||
let dir = sandbox("success");
|
||||
let result = create_sample_import_folders(as_arg(&dir), "2026-09-25".to_string());
|
||||
let read = |folder: &str| {
|
||||
fs::read_to_string(dir.join(folder).join("releve-2026-08.csv"))
|
||||
};
|
||||
let desjardins = read(SAMPLE_DESJARDINS_FOLDER);
|
||||
let tangerine = read(SAMPLE_TANGERINE_FOLDER);
|
||||
let desjardins_count = fs::read_dir(dir.join(SAMPLE_DESJARDINS_FOLDER)).unwrap().count();
|
||||
let tangerine_count = fs::read_dir(dir.join(SAMPLE_TANGERINE_FOLDER)).unwrap().count();
|
||||
cleanup(&dir);
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
vec![
|
||||
SAMPLE_DESJARDINS_FOLDER.to_string(),
|
||||
SAMPLE_TANGERINE_FOLDER.to_string()
|
||||
]
|
||||
);
|
||||
let desjardins = desjardins.expect("Desjardins file written");
|
||||
let tangerine = tangerine.expect("Tangerine file written");
|
||||
assert_eq!(desjardins_count, 1);
|
||||
assert_eq!(tangerine_count, 1);
|
||||
assert_eq!(desjardins.lines().count(), 41);
|
||||
assert_eq!(tangerine.lines().count(), 41);
|
||||
assert_dates_in(&desjardins, &["08/2026", "07/2026"]);
|
||||
assert_dates_in(&tangerine, &["08/2026", "07/2026"]);
|
||||
assert!(desjardins.contains("08/2026") && desjardins.contains("07/2026"));
|
||||
assert!(tangerine.contains("08/2026") && tangerine.contains("07/2026"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_an_unparseable_date_before_writing() {
|
||||
let dir = sandbox("bad-date");
|
||||
let result = create_sample_import_folders(as_arg(&dir), "pas-une-date".to_string());
|
||||
let anything_written = fs::read_dir(&dir).unwrap().next().is_some();
|
||||
cleanup(&dir);
|
||||
let err = result.unwrap_err();
|
||||
assert!(err.contains("Invalid date"), "{}", err);
|
||||
assert!(!anything_written);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crosses_the_year_boundary() {
|
||||
let dir = sandbox("year-boundary");
|
||||
let result = create_sample_import_folders(as_arg(&dir), "2027-01-15".to_string());
|
||||
let desjardins =
|
||||
fs::read_to_string(dir.join(SAMPLE_DESJARDINS_FOLDER).join("releve-2026-12.csv"));
|
||||
let tangerine =
|
||||
fs::read_to_string(dir.join(SAMPLE_TANGERINE_FOLDER).join("releve-2026-12.csv"));
|
||||
cleanup(&dir);
|
||||
|
||||
assert!(result.is_ok());
|
||||
let desjardins = desjardins.expect("Desjardins file named after December");
|
||||
let tangerine = tangerine.expect("Tangerine file named after December");
|
||||
assert_dates_in(&desjardins, &["12/2026", "11/2026"]);
|
||||
assert_dates_in(&tangerine, &["12/2026", "11/2026"]);
|
||||
assert!(desjardins.contains("12/2026") && desjardins.contains("11/2026"));
|
||||
assert!(tangerine.contains("12/2026") && tangerine.contains("11/2026"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod scan_import_folder_tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// One scratch directory per case: the cases run in parallel in one process.
|
||||
fn sandbox(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"simpl-resultat-scan-{}-{}",
|
||||
std::process::id(),
|
||||
name
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).expect("sandbox created");
|
||||
dir
|
||||
}
|
||||
|
||||
fn touch(path: &Path) {
|
||||
fs::write(path, "Date;Montant\n").expect("file written");
|
||||
}
|
||||
|
||||
fn scan(dir: &Path) -> ImportFolderScan {
|
||||
scan_import_folder(dir.to_string_lossy().to_string()).expect("scan succeeds")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_the_root_statement_files_sorted_by_name() {
|
||||
let dir = sandbox("root-sorted");
|
||||
touch(&dir.join("b.csv"));
|
||||
touch(&dir.join("a.csv"));
|
||||
touch(&dir.join("c.TXT"));
|
||||
fs::create_dir_all(dir.join("visa")).unwrap();
|
||||
touch(&dir.join("visa").join("releve.csv"));
|
||||
|
||||
let result = scan(&dir);
|
||||
assert_eq!(result.root_files, vec!["a.csv", "b.csv", "c.TXT"]);
|
||||
assert_eq!(result.sources.len(), 1);
|
||||
assert_eq!(result.sources[0].folder_name, "visa");
|
||||
assert_eq!(result.sources[0].files.len(), 1);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_subfolder_files_out_of_root_files() {
|
||||
let dir = sandbox("subfolder-only");
|
||||
fs::create_dir_all(dir.join("cheques")).unwrap();
|
||||
touch(&dir.join("cheques").join("a.csv"));
|
||||
|
||||
let result = scan(&dir);
|
||||
assert!(result.root_files.is_empty());
|
||||
assert_eq!(result.sources[0].files[0].filename, "a.csv");
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_other_extensions_and_hidden_files_at_the_root() {
|
||||
let dir = sandbox("ignored");
|
||||
touch(&dir.join("notes.pdf"));
|
||||
touch(&dir.join("noextension"));
|
||||
touch(&dir.join(".hidden.csv"));
|
||||
touch(&dir.join("kept.Csv"));
|
||||
|
||||
let result = scan(&dir);
|
||||
assert_eq!(result.root_files, vec!["kept.Csv"]);
|
||||
assert!(result.sources.is_empty());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_no_root_file_for_a_root_without_files() {
|
||||
let dir = sandbox("empty-root");
|
||||
fs::create_dir_all(dir.join("visa")).unwrap();
|
||||
|
||||
let result = scan(&dir);
|
||||
assert!(result.root_files.is_empty());
|
||||
assert_eq!(result.sources.len(), 1);
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_root_files_in_snake_case() {
|
||||
let dir = sandbox("serialize");
|
||||
touch(&dir.join("a.csv"));
|
||||
|
||||
let json = serde_json::to_value(scan(&dir)).unwrap();
|
||||
assert_eq!(json["root_files"], serde_json::json!(["a.csv"]));
|
||||
assert!(json["sources"].is_array());
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3,10 +3,12 @@ use rand::RngCore;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256, Sha384};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use subtle::ConstantTimeEq;
|
||||
use tauri::Manager;
|
||||
|
||||
use crate::database;
|
||||
use crate::database::profile_db;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Profile {
|
||||
|
|
@ -32,6 +34,49 @@ fn get_profiles_path(app: &tauri::AppHandle) -> Result<std::path::PathBuf, Strin
|
|||
Ok(app_dir.join("profiles.json"))
|
||||
}
|
||||
|
||||
/// Folder where tauri-plugin-sql keeps every profile database.
|
||||
///
|
||||
/// The plugin opens `sqlite:<file>` under `app_config_dir()` (tauri-plugin-sql
|
||||
/// 2.3.2, `wrapper.rs`: `DbPool::connect` then `path_mapper`), not under
|
||||
/// `app_data_dir()`, where `profiles.json` and the licence files live. Both
|
||||
/// are the same folder on Windows and macOS, not on Linux (`~/.config/<id>`
|
||||
/// and `~/.local/share/<id>`): until #390, every command that looked for a
|
||||
/// profile database under `app_data_dir()` found nothing on Linux.
|
||||
pub(crate) fn profile_db_dir<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Result<PathBuf, String> {
|
||||
app.path()
|
||||
.app_config_dir()
|
||||
.map_err(|e| format!("Cannot get app config dir: {}", e))
|
||||
}
|
||||
|
||||
/// Path of a profile database, from the bare file name the webview passes.
|
||||
pub(crate) fn profile_db_path<R: tauri::Runtime>(
|
||||
app: &tauri::AppHandle<R>,
|
||||
db_filename: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
profile_db_path_in(&profile_db_dir(app)?, db_filename)
|
||||
}
|
||||
|
||||
/// Accept only the two shapes the app ever gives a profile database:
|
||||
/// `simpl_resultat.db` and `profile_<hex>.db` (ProfileContext, since v0.3.0).
|
||||
/// `Path::join` with an absolute value would replace the folder, and these
|
||||
/// commands write and delete.
|
||||
pub(crate) fn profile_db_path_in(dir: &Path, db_filename: &str) -> Result<PathBuf, String> {
|
||||
if !is_profile_db_filename(db_filename) {
|
||||
return Err("invalid_profile_db_filename".to_string());
|
||||
}
|
||||
Ok(dir.join(db_filename))
|
||||
}
|
||||
|
||||
fn is_profile_db_filename(name: &str) -> bool {
|
||||
if name == database::DEFAULT_PROFILE_DB {
|
||||
return true;
|
||||
}
|
||||
match name.strip_prefix("profile_").and_then(|rest| rest.strip_suffix(".db")) {
|
||||
Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_hexdigit()),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_default_config() -> ProfilesConfig {
|
||||
let now = chrono_now();
|
||||
let default_id = "default".to_string();
|
||||
|
|
@ -92,35 +137,64 @@ pub fn save_profiles(app: tauri::AppHandle, config: ProfilesConfig) -> Result<()
|
|||
fs::write(&path, json).map_err(|e| format!("Cannot write profiles.json: {}", e))
|
||||
}
|
||||
|
||||
/// Delete a secondary profile's database, with its SQLite side files and the
|
||||
/// copies a catch-up took (#390): the guide promises that deleting a profile
|
||||
/// deletes all its data.
|
||||
#[tauri::command]
|
||||
pub fn delete_profile_db(app: tauri::AppHandle, db_filename: String) -> Result<(), String> {
|
||||
if db_filename == "simpl_resultat.db" {
|
||||
if db_filename == database::DEFAULT_PROFILE_DB {
|
||||
return Err("Cannot delete the default profile database".to_string());
|
||||
}
|
||||
let db_path = profile_db_path(&app, &db_filename)?;
|
||||
delete_profile_files(&db_path, &db_filename)
|
||||
}
|
||||
|
||||
let app_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("Cannot get app data dir: {}", e))?;
|
||||
let db_path = app_dir.join(&db_filename);
|
||||
|
||||
if db_path.exists() {
|
||||
fs::remove_file(&db_path)
|
||||
.map_err(|e| format!("Cannot delete database file: {}", e))?;
|
||||
fn delete_profile_files(db_path: &Path, db_filename: &str) -> Result<(), String> {
|
||||
for suffix in ["", "-wal", "-shm", "-journal"] {
|
||||
let path = db_path.with_file_name(format!("{}{}", db_filename, suffix));
|
||||
if path.exists() {
|
||||
fs::remove_file(&path).map_err(|e| format!("Cannot delete database file: {}", e))?;
|
||||
}
|
||||
}
|
||||
if let Some(dir) = db_path.parent() {
|
||||
profile_db::remove_backups(
|
||||
&dir.join(profile_db::MIGRATION_BACKUP_DIR),
|
||||
db_filename.trim_end_matches(".db"),
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// SQL that creates a secondary profile, run by the webview through the
|
||||
/// plugin (`initializeNewProfileDb`).
|
||||
#[tauri::command]
|
||||
pub fn get_new_profile_init_sql() -> Result<Vec<String>, String> {
|
||||
// Brand-new profiles ship with the v1 IPC-aligned category taxonomy: the
|
||||
// consolidated schema bakes the v1 seed (categories + keywords + the
|
||||
// categories_schema_version='v1' preference) directly. The legacy v2 seed
|
||||
// migration still runs first because tauri-plugin-sql applies every
|
||||
// declared migration on `Database.load`; the consolidated script then
|
||||
// deletes the v2 rows and re-inserts the v1 rows in the 1000+ id range.
|
||||
Ok(vec![database::CONSOLIDATED_SCHEMA.to_string()])
|
||||
// The consolidated schema is the whole database: tauri-plugin-sql runs no
|
||||
// migration for a `profile_<id>.db`, only the default profile's URL is
|
||||
// registered (lib.rs). It carries the v1 IPC-aligned taxonomy (categories,
|
||||
// keywords, categories_schema_version='v1'). The second statement writes
|
||||
// the complete migration history, so later versions reach the profile
|
||||
// through `prepare_profile_db` without inferring anything (#390).
|
||||
Ok(vec![
|
||||
database::CONSOLIDATED_SCHEMA.to_string(),
|
||||
profile_db::history_sql(&crate::migrations()),
|
||||
])
|
||||
}
|
||||
|
||||
/// Bring a profile database up to date and repair legacy values before the
|
||||
/// webview opens it (#390, ADR 0021). Secondary profiles catch up on the
|
||||
/// migrations they missed; every profile gets its text manual-categorization
|
||||
/// flags repaired. Sync on purpose: Tauri runs sync commands on the main
|
||||
/// thread, which serializes two opens of the same profile (React StrictMode
|
||||
/// mounts twice in development).
|
||||
#[tauri::command]
|
||||
pub fn prepare_profile_db(
|
||||
app: tauri::AppHandle,
|
||||
db_filename: String,
|
||||
) -> Result<profile_db::ProfilePreparation, String> {
|
||||
let db_path = profile_db_path(&app, &db_filename)?;
|
||||
profile_db::prepare_at(&db_path, &db_filename, &crate::migrations())
|
||||
}
|
||||
|
||||
// Argon2id parameters for PIN hashing (same as export_import_commands.rs)
|
||||
|
|
@ -225,11 +299,7 @@ fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
|
|||
/// of destructive migrations (e.g., migration 2 which DELETEs categories/keywords).
|
||||
#[tauri::command]
|
||||
pub fn repair_migrations(app: tauri::AppHandle, db_filename: String) -> Result<bool, String> {
|
||||
let app_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("Cannot get app data dir: {}", e))?;
|
||||
let db_path = app_dir.join(&db_filename);
|
||||
let db_path = profile_db_path(&app, &db_filename)?;
|
||||
|
||||
if !db_path.exists() {
|
||||
return Ok(false);
|
||||
|
|
@ -375,4 +445,91 @@ mod tests {
|
|||
let decoded = hex_decode(&encoded).unwrap();
|
||||
assert_eq!(original, decoded);
|
||||
}
|
||||
|
||||
// -- profile database location (#390) ------------------------------------
|
||||
|
||||
#[test]
|
||||
fn profile_databases_live_where_the_sql_plugin_opens_them() {
|
||||
// tauri-plugin-sql resolves `sqlite:<file>` under app_config_dir().
|
||||
// On Linux (the CI platform) that differs from app_data_dir(), so
|
||||
// going back to the data folder turns this red.
|
||||
let app = tauri::test::mock_app();
|
||||
let path = profile_db_path(app.handle(), "profile_0a1b2c3d.db").unwrap();
|
||||
assert_eq!(path.parent(), Some(app.path().app_config_dir().unwrap().as_path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_db_path_accepts_the_two_shapes_the_app_uses() {
|
||||
let dir = Path::new("/profiles");
|
||||
assert_eq!(
|
||||
profile_db_path_in(dir, "simpl_resultat.db"),
|
||||
Ok(dir.join("simpl_resultat.db"))
|
||||
);
|
||||
assert_eq!(
|
||||
profile_db_path_in(dir, "profile_0a1b2c3d.db"),
|
||||
Ok(dir.join("profile_0a1b2c3d.db"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_db_path_refuses_anything_else() {
|
||||
let dir = Path::new("/profiles");
|
||||
for name in [
|
||||
"",
|
||||
"/etc/passwd",
|
||||
"../simpl_resultat.db",
|
||||
"profile_.db",
|
||||
"profile_0a1b/../x.db",
|
||||
"profile_0a1b2c3d.sqlite",
|
||||
"profile_0a1b2c3d.db.bak",
|
||||
"PROFILE_0a1b2c3d.db",
|
||||
"profile_xyz.db",
|
||||
"C:\\Users\\x\\profile_0a1b2c3d.db",
|
||||
] {
|
||||
assert_eq!(
|
||||
profile_db_path_in(dir, name),
|
||||
Err("invalid_profile_db_filename".to_string()),
|
||||
"{:?}",
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_profile_removes_its_files_and_only_its_files() {
|
||||
let dir = profile_db::TestDir::new("delete");
|
||||
let backups = dir.join(profile_db::MIGRATION_BACKUP_DIR);
|
||||
fs::create_dir_all(&backups).unwrap();
|
||||
for name in [
|
||||
"profile_0a1b2c3d.db",
|
||||
// The only side file this app produces: sqlx leaves SQLite's
|
||||
// rollback journal by default, `-wal`/`-shm` come with WAL.
|
||||
"profile_0a1b2c3d.db-journal",
|
||||
"profile_0a1b2c3d.db-wal",
|
||||
"profile_0a1b2c3d.db-shm",
|
||||
"profile_99999999.db",
|
||||
] {
|
||||
fs::write(dir.join(name), b"x").unwrap();
|
||||
}
|
||||
for name in ["profile_0a1b2c3d-before-v7.db", "profile_99999999-before-v17.db"] {
|
||||
fs::write(backups.join(name), b"x").unwrap();
|
||||
}
|
||||
|
||||
delete_profile_files(&dir.join("profile_0a1b2c3d.db"), "profile_0a1b2c3d.db").unwrap();
|
||||
|
||||
let mut left: Vec<String> = fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.chain(fs::read_dir(&backups).unwrap())
|
||||
.map(|e| e.unwrap().file_name().into_string().unwrap())
|
||||
.collect();
|
||||
left.sort();
|
||||
assert_eq!(
|
||||
left,
|
||||
vec![
|
||||
"migration-backups".to_string(),
|
||||
"profile_99999999-before-v17.db".to_string(),
|
||||
"profile_99999999.db".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,11 @@ CREATE TABLE IF NOT EXISTS import_sources (
|
|||
column_mapping TEXT NOT NULL,
|
||||
skip_lines INTEGER NOT NULL DEFAULT 0,
|
||||
has_header INTEGER NOT NULL DEFAULT 1,
|
||||
-- Full import format (migration v17). These four columns are INERT on the
|
||||
-- production path: this script runs AFTER tauri-plugin-sql has applied every
|
||||
-- migration and only uses CREATE TABLE IF NOT EXISTS, so a brand-new profile
|
||||
-- actually receives them from v17. They are mirrored here to keep this file
|
||||
-- the tested reference definition -- a parity test compares it against the
|
||||
-- v1->v17 chain so the DEFAULT and CHECK of the two cannot drift apart.
|
||||
-- Full import format (migration v17). A secondary profile receives these
|
||||
-- four columns from this script, not from v17: tauri-plugin-sql runs no
|
||||
-- migration on a `profile_<id>.db` (#390, ADR 0021). A parity test compares
|
||||
-- this definition with the v1->v17 chain so the DEFAULT and CHECK of the
|
||||
-- two cannot drift apart.
|
||||
-- `amount_mode` admits 'absolute_indicator' from the start so the third mode
|
||||
-- can ship without another migration. `template_id` is a provenance tag
|
||||
-- only, never re-read as format: the eight format columns above are
|
||||
|
|
@ -374,9 +373,9 @@ INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('balance_starter_pro
|
|||
-- ============================================================================
|
||||
-- Seed v1 — IPC Statistique Canada-aligned, 3 levels, Canada/Québec
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Reset any pre-existing category data (possible when tauri-plugin-sql runs
|
||||
-- the historical v2 seed migration on this fresh DB before this script
|
||||
-- executes). Keywords/categories are wiped and re-inserted with the v1 IDs
|
||||
-- Reset any pre-existing category data. Defensive only: tauri-plugin-sql runs
|
||||
-- no migration on a secondary profile's database, so no v2 seed precedes this
|
||||
-- script (#390). Keywords/categories are wiped and re-inserted with the v1 IDs
|
||||
-- (1000+ range) so existing references in migrations v3-v7 stay inert.
|
||||
-- ============================================================================
|
||||
DELETE FROM keywords;
|
||||
|
|
|
|||
824
src-tauri/src/database/fixtures/consolidated_v0.10.0.sql
Normal file
824
src-tauri/src/database/fixtures/consolidated_v0.10.0.sql
Normal file
|
|
@ -0,0 +1,824 @@
|
|||
-- Consolidated schema for new profile databases
|
||||
-- This file bakes in the base schema + all migrations (v3-v8) and pre-seeds
|
||||
-- the v1 IPC-aligned category taxonomy so that brand-new profiles immediately
|
||||
-- use the new standard. Existing profiles keep their v2 seed and are only
|
||||
-- tagged via the migration (categories_schema_version = 'v2').
|
||||
-- Used ONLY for initializing new profile databases (not for the default profile).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_sources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
date_format TEXT NOT NULL DEFAULT '%d/%m/%Y',
|
||||
delimiter TEXT NOT NULL DEFAULT ';',
|
||||
encoding TEXT NOT NULL DEFAULT 'utf-8',
|
||||
column_mapping TEXT NOT NULL,
|
||||
skip_lines INTEGER NOT NULL DEFAULT 0,
|
||||
has_header INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS imported_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_id INTEGER NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
file_hash TEXT NOT NULL,
|
||||
import_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
row_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
notes TEXT,
|
||||
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE CASCADE,
|
||||
UNIQUE(source_id, filename)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
parent_id INTEGER,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'expense',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_inputable INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
i18n_key TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS suppliers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
normalized_name TEXT NOT NULL,
|
||||
category_id INTEGER,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS keywords (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
supplier_id INTEGER,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
|
||||
UNIQUE(keyword, category_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date DATE NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
category_id INTEGER,
|
||||
supplier_id INTEGER,
|
||||
source_id INTEGER,
|
||||
file_id INTEGER,
|
||||
original_description TEXT,
|
||||
notes TEXT,
|
||||
is_manually_categorized INTEGER NOT NULL DEFAULT 0,
|
||||
is_split INTEGER NOT NULL DEFAULT 0,
|
||||
parent_transaction_id INTEGER,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (file_id) REFERENCES imported_files(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (parent_transaction_id) REFERENCES transactions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS adjustments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
date DATE NOT NULL,
|
||||
is_recurring INTEGER NOT NULL DEFAULT 0,
|
||||
recurrence_rule TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS adjustment_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
adjustment_id INTEGER NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
description TEXT,
|
||||
FOREIGN KEY (adjustment_id) REFERENCES adjustments(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category_id INTEGER NOT NULL,
|
||||
year INTEGER NOT NULL,
|
||||
month INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
notes TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
|
||||
UNIQUE(category_id, year, month)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_template_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
template_id INTEGER NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
FOREIGN KEY (template_id) REFERENCES budget_templates(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
|
||||
UNIQUE(template_id, category_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_config_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
delimiter TEXT NOT NULL DEFAULT ';',
|
||||
encoding TEXT NOT NULL DEFAULT 'utf-8',
|
||||
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
|
||||
skip_lines INTEGER NOT NULL DEFAULT 0,
|
||||
has_header INTEGER NOT NULL DEFAULT 1,
|
||||
column_mapping TEXT NOT NULL,
|
||||
amount_mode TEXT NOT NULL DEFAULT 'single',
|
||||
sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_supplier ON transactions(supplier_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_source ON transactions(source_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_file ON transactions(file_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_parent ON transactions(parent_transaction_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_suppliers_category ON suppliers(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_suppliers_normalized ON suppliers(normalized_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_keywords_category ON keywords(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_keywords_keyword ON keywords(keyword);
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, month);
|
||||
CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- Balance sheet (Bilan) — Migration v9 mirror for new profiles
|
||||
-- ============================================================================
|
||||
-- 5 tables + 7 indexes + seeded categories. Kept in sync with
|
||||
-- `balance_schema.sql` (the source of truth applied by Migration v9 in lib.rs).
|
||||
-- New profiles created from this consolidated schema get the balance feature
|
||||
-- preinstalled without needing to replay v9 separately.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
i18n_key TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('simple','priced')),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_seed INTEGER NOT NULL DEFAULT 0,
|
||||
asset_type TEXT CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto')),
|
||||
-- User-facing override for the category label (migration v12). When set,
|
||||
-- the UI shows this verbatim; otherwise it falls back to t(i18n_key).
|
||||
custom_label TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
balance_category_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
symbol TEXT,
|
||||
currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'),
|
||||
notes TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
archived_at DATETIME,
|
||||
-- Fiscal envelope / tax shelter (migration v12). NULL = no envelope (e.g.
|
||||
-- a chequing account or crypto wallet). NOT an automobile type.
|
||||
vehicle_type TEXT CHECK(vehicle_type IS NULL OR vehicle_type IN ('unregistered','tfsa','rrsp','rrif','fhsa','resp')),
|
||||
-- Entry mode (migration v15). 'simple' = one denormalized value per
|
||||
-- snapshot line; 'detailed' = a basket of per-security holdings (see
|
||||
-- balance_snapshot_holdings). Accounts under a priced category are backfilled
|
||||
-- to 'detailed' below. `detailed_since` is the authoritative pivot date from
|
||||
-- which detailed entry is expected — NULL until the conversion flow sets it.
|
||||
kind TEXT NOT NULL DEFAULT 'simple' CHECK (kind IN ('simple','detailed')),
|
||||
detailed_since DATE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapshot_date DATE NOT NULL UNIQUE,
|
||||
notes TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_snapshot_lines (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapshot_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
quantity REAL,
|
||||
unit_price REAL,
|
||||
value REAL NOT NULL,
|
||||
price_source TEXT,
|
||||
price_fetched_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT,
|
||||
UNIQUE(snapshot_id, account_id),
|
||||
CHECK (
|
||||
(quantity IS NULL AND unit_price IS NULL)
|
||||
OR (quantity IS NOT NULL AND unit_price IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_account_transfers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
transaction_id INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL CHECK(direction IN ('in','out')),
|
||||
notes TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT,
|
||||
UNIQUE(transaction_id, account_id)
|
||||
);
|
||||
|
||||
-- Détail par titre (Bilan Étape 2, migration v14). Kept in sync with
|
||||
-- `balance_holdings_schema.sql` (the source of truth applied by Migration v14
|
||||
-- in lib.rs). balance_securities is the instrument catalogue (symbol normalized
|
||||
-- upper/trim, COLLATE NOCASE UNIQUE); balance_snapshot_holdings is the per-title
|
||||
-- breakdown of a detailed account's snapshot line.
|
||||
CREATE TABLE IF NOT EXISTS balance_securities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
||||
name TEXT,
|
||||
currency TEXT NOT NULL DEFAULT 'CAD',
|
||||
asset_type TEXT NOT NULL CHECK (asset_type IN ('stock','crypto')),
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_snapshot_holdings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapshot_line_id INTEGER NOT NULL REFERENCES balance_snapshot_lines(id) ON DELETE CASCADE,
|
||||
security_id INTEGER NOT NULL REFERENCES balance_securities(id) ON DELETE RESTRICT,
|
||||
quantity REAL NOT NULL,
|
||||
unit_price REAL NOT NULL,
|
||||
value REAL NOT NULL,
|
||||
book_cost REAL,
|
||||
price_source TEXT,
|
||||
price_fetched_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (snapshot_line_id, security_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1;
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_holdings_line ON balance_snapshot_holdings(snapshot_line_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_holdings_security ON balance_snapshot_holdings(security_id);
|
||||
|
||||
-- Asset classes only (Bilan axe véhicule, Étape 1). The former `tfsa` / `rrsp`
|
||||
-- seeds were fiscal envelopes, not asset classes — they now live on the account
|
||||
-- as `vehicle_type` (see balance_accounts.vehicle_type). New profiles ship with
|
||||
-- exactly 5 asset classes: Liquidités, Fonds/FNB, Actions, Crypto, Autres.
|
||||
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed, asset_type) VALUES
|
||||
('cash', 'balance.category.cash', 'simple', 10, 1, NULL),
|
||||
('fund', 'balance.category.fund', 'simple', 40, 1, NULL),
|
||||
('other', 'balance.category.other', 'simple', 50, 1, NULL),
|
||||
('stock', 'balance.category.stock', 'priced', 60, 1, 'stock'),
|
||||
('crypto', 'balance.category.crypto', 'priced', 70, 1, 'crypto');
|
||||
|
||||
-- Starter accounts (Issue #179): 4 plain accounts seeded for new profiles so
|
||||
-- /balance lands non-empty. They are NOT marked as seed (no is_seed column on
|
||||
-- balance_accounts) — once created they are indistinguishable from
|
||||
-- user-created accounts and can be renamed/archived freely. Existing profiles
|
||||
-- get the same 4 proposed via StarterAccountsModal on first /balance visit.
|
||||
--
|
||||
-- Bilan axe véhicule (Étape 1): the CELI / REER starters are no longer linked
|
||||
-- to a `tfsa` / `rrsp` category (those seeds are gone). They now attach to the
|
||||
-- `other` asset class and carry the envelope in `vehicle_type`. Linking them to
|
||||
-- a deleted key would make the subselect return NULL → NOT NULL violation →
|
||||
-- broken new-profile init, so the asset class MUST resolve to `other`.
|
||||
INSERT INTO balance_accounts (balance_category_id, name, currency, is_active, vehicle_type) VALUES
|
||||
((SELECT id FROM balance_categories WHERE key = 'cash'), 'Compte chèque', 'CAD', 1, NULL),
|
||||
((SELECT id FROM balance_categories WHERE key = 'other'), 'CELI', 'CAD', 1, 'tfsa'),
|
||||
((SELECT id FROM balance_categories WHERE key = 'other'), 'REER', 'CAD', 1, 'rrsp'),
|
||||
((SELECT id FROM balance_categories WHERE key = 'other'), 'Compte non-enregistré', 'CAD', 1, NULL);
|
||||
|
||||
-- Détail par titre (migration v15 mirror): accounts under a priced category are
|
||||
-- detailed-entry by default. The 4 starters above all sit under simple asset
|
||||
-- classes (cash / other), so this is currently a no-op — kept at parity with the
|
||||
-- v15 backfill so any future priced starter is stamped correctly.
|
||||
UPDATE balance_accounts SET kind = 'detailed'
|
||||
WHERE balance_category_id IN (SELECT id FROM balance_categories WHERE kind = 'priced');
|
||||
|
||||
-- Default preferences (new profiles ship with the v1 IPC taxonomy)
|
||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
|
||||
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 ('date_format', 'DD/MM/YYYY');
|
||||
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
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Reset any pre-existing category data (possible when tauri-plugin-sql runs
|
||||
-- the historical v2 seed migration on this fresh DB before this script
|
||||
-- executes). Keywords/categories are wiped and re-inserted with the v1 IDs
|
||||
-- (1000+ range) so existing references in migrations v3-v7 stay inert.
|
||||
-- ============================================================================
|
||||
DELETE FROM keywords;
|
||||
UPDATE transactions SET category_id = NULL;
|
||||
DELETE FROM categories;
|
||||
|
||||
-- LEVEL 1 — Roots (9 IPC components + Revenus + Transferts)
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1000, 'Revenus', NULL, 'income', '#16a34a', 0, 1, 'categoriesSeed.revenus.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1100, 'Alimentation', NULL, 'expense', '#ea580c', 0, 2, 'categoriesSeed.alimentation.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1200, 'Logement', NULL, 'expense', '#dc2626', 0, 3, 'categoriesSeed.logement.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1300, 'Ménage & ameublement', NULL, 'expense', '#ca8a04', 0, 4, 'categoriesSeed.menage.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1400, 'Vêtements & chaussures', NULL, 'expense', '#d946ef', 0, 5, 'categoriesSeed.vetements.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1500, 'Transport', NULL, 'expense', '#2563eb', 0, 6, 'categoriesSeed.transport.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1600, 'Santé & soins personnels', NULL, 'expense', '#f43f5e', 0, 7, 'categoriesSeed.sante.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1700, 'Loisirs, formation & lecture', NULL, 'expense', '#8b5cf6', 0, 8, 'categoriesSeed.loisirs.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1800, 'Boissons, tabac & cannabis', NULL, 'expense', '#7c3aed', 0, 9, 'categoriesSeed.consommation.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1900, 'Finances & obligations', NULL, 'expense', '#6b7280', 0, 10, 'categoriesSeed.finances.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1950, 'Transferts & placements', NULL, 'transfer', '#0ea5e9', 0, 11, 'categoriesSeed.transferts.root');
|
||||
|
||||
-- 1000 — Revenus
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1010, 'Emploi', 1000, 'income', '#22c55e', 0, 1, 'categoriesSeed.revenus.emploi.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1011, 'Paie régulière', 1010, 'income', '#22c55e', 1, 1, 'categoriesSeed.revenus.emploi.paie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1012, 'Primes & bonus', 1010, 'income', '#4ade80', 1, 2, 'categoriesSeed.revenus.emploi.primes');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1013, 'Travail autonome', 1010, 'income', '#86efac', 1, 3, 'categoriesSeed.revenus.emploi.autonome');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1020, 'Gouvernemental', 1000, 'income', '#16a34a', 0, 2, 'categoriesSeed.revenus.gouvernemental.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1021, 'Remboursement impôt', 1020, 'income', '#16a34a', 1, 1, 'categoriesSeed.revenus.gouvernemental.remboursementImpot');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1022, 'Allocations familiales', 1020, 'income', '#15803d', 1, 2, 'categoriesSeed.revenus.gouvernemental.allocations');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1023, 'Crédits TPS/TVQ', 1020, 'income', '#166534', 1, 3, 'categoriesSeed.revenus.gouvernemental.credits');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1024, 'Assurance-emploi / RQAP', 1020, 'income', '#14532d', 1, 4, 'categoriesSeed.revenus.gouvernemental.assuranceEmploi');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1030, 'Revenus de placement', 1000, 'income', '#10b981', 0, 3, 'categoriesSeed.revenus.placement.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1031, 'Intérêts & dividendes', 1030, 'income', '#10b981', 1, 1, 'categoriesSeed.revenus.placement.interets');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1032, 'Gains en capital', 1030, 'income', '#059669', 1, 2, 'categoriesSeed.revenus.placement.capital');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1033, 'Revenus locatifs', 1030, 'income', '#047857', 1, 3, 'categoriesSeed.revenus.placement.locatifs');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1090, 'Autres revenus', 1000, 'income', '#84cc16', 1, 9, 'categoriesSeed.revenus.autres');
|
||||
|
||||
-- 1100 — Alimentation
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1110, 'Épicerie & marché', 1100, 'expense', '#ea580c', 0, 1, 'categoriesSeed.alimentation.epicerie.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1111, 'Épicerie régulière', 1110, 'expense', '#ea580c', 1, 1, 'categoriesSeed.alimentation.epicerie.reguliere');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1112, 'Boucherie & poissonnerie', 1110, 'expense', '#c2410c', 1, 2, 'categoriesSeed.alimentation.epicerie.boucherie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1113, 'Boulangerie & pâtisserie', 1110, 'expense', '#9a3412', 1, 3, 'categoriesSeed.alimentation.epicerie.boulangerie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1114, 'Dépanneur', 1110, 'expense', '#7c2d12', 1, 4, 'categoriesSeed.alimentation.epicerie.depanneur');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1115, 'Marché & produits spécialisés', 1110, 'expense', '#fb923c', 1, 5, 'categoriesSeed.alimentation.epicerie.marche');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1120, 'Restauration', 1100, 'expense', '#f97316', 0, 2, 'categoriesSeed.alimentation.restauration.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1121, 'Restaurant', 1120, 'expense', '#f97316', 1, 1, 'categoriesSeed.alimentation.restauration.restaurant');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1122, 'Café & boulangerie rapide', 1120, 'expense', '#fb923c', 1, 2, 'categoriesSeed.alimentation.restauration.cafe');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1123, 'Restauration rapide', 1120, 'expense', '#fdba74', 1, 3, 'categoriesSeed.alimentation.restauration.fastfood');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1124, 'Livraison à domicile', 1120, 'expense', '#fed7aa', 1, 4, 'categoriesSeed.alimentation.restauration.livraison');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1125, 'Cantine & cafétéria', 1120, 'expense', '#ffedd5', 1, 5, 'categoriesSeed.alimentation.restauration.cantine');
|
||||
|
||||
-- 1200 — Logement
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1210, 'Habitation principale', 1200, 'expense', '#dc2626', 0, 1, 'categoriesSeed.logement.habitation.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1211, 'Loyer', 1210, 'expense', '#dc2626', 1, 1, 'categoriesSeed.logement.habitation.loyer');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1212, 'Hypothèque', 1210, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.logement.habitation.hypotheque');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1213, 'Taxes municipales & scolaires', 1210, 'expense', '#991b1b', 1, 3, 'categoriesSeed.logement.habitation.taxes');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1214, 'Charges de copropriété', 1210, 'expense', '#7f1d1d', 1, 4, 'categoriesSeed.logement.habitation.copropriete');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1220, 'Services publics', 1200, 'expense', '#ef4444', 0, 2, 'categoriesSeed.logement.services.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1221, 'Électricité', 1220, 'expense', '#ef4444', 1, 1, 'categoriesSeed.logement.services.electricite');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1222, 'Gaz naturel', 1220, 'expense', '#f87171', 1, 2, 'categoriesSeed.logement.services.gaz');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1223, 'Chauffage (mazout, propane)', 1220, 'expense', '#fca5a5', 1, 3, 'categoriesSeed.logement.services.chauffage');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1224, 'Eau & égouts', 1220, 'expense', '#fecaca', 1, 4, 'categoriesSeed.logement.services.eau');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1230, 'Communications', 1200, 'expense', '#6366f1', 0, 3, 'categoriesSeed.logement.communications.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1231, 'Internet résidentiel', 1230, 'expense', '#6366f1', 1, 1, 'categoriesSeed.logement.communications.internet');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1232, 'Téléphonie mobile', 1230, 'expense', '#818cf8', 1, 2, 'categoriesSeed.logement.communications.mobile');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1233, 'Téléphonie résidentielle', 1230, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.logement.communications.residentielle');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1234, 'Câblodistribution & streaming TV', 1230, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.logement.communications.streamingTv');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1240, 'Entretien & réparations', 1200, 'expense', '#e11d48', 0, 4, 'categoriesSeed.logement.entretien.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1241, 'Entretien général', 1240, 'expense', '#e11d48', 1, 1, 'categoriesSeed.logement.entretien.general');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1242, 'Rénovations', 1240, 'expense', '#be123c', 1, 2, 'categoriesSeed.logement.entretien.renovations');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1243, 'Matériaux & outils', 1240, 'expense', '#9f1239', 1, 3, 'categoriesSeed.logement.entretien.materiaux');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1244, 'Aménagement paysager', 1240, 'expense', '#881337', 1, 4, 'categoriesSeed.logement.entretien.paysager');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1250, 'Assurance habitation', 1200, 'expense', '#14b8a6', 1, 5, 'categoriesSeed.logement.assurance');
|
||||
|
||||
-- 1300 — Ménage & ameublement
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1310, 'Ameublement', 1300, 'expense', '#ca8a04', 0, 1, 'categoriesSeed.menage.ameublement.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1311, 'Meubles', 1310, 'expense', '#ca8a04', 1, 1, 'categoriesSeed.menage.ameublement.meubles');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1312, 'Électroménagers', 1310, 'expense', '#a16207', 1, 2, 'categoriesSeed.menage.ameublement.electromenagers');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1313, 'Décoration', 1310, 'expense', '#854d0e', 1, 3, 'categoriesSeed.menage.ameublement.decoration');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1320, 'Fournitures ménagères', 1300, 'expense', '#eab308', 0, 2, 'categoriesSeed.menage.fournitures.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1321, 'Produits d''entretien', 1320, 'expense', '#eab308', 1, 1, 'categoriesSeed.menage.fournitures.entretien');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1322, 'Literie & linge de maison', 1320, 'expense', '#facc15', 1, 2, 'categoriesSeed.menage.fournitures.literie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1323, 'Vaisselle & ustensiles', 1320, 'expense', '#fde047', 1, 3, 'categoriesSeed.menage.fournitures.vaisselle');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1330, 'Services domestiques', 1300, 'expense', '#fbbf24', 0, 3, 'categoriesSeed.menage.services.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1331, 'Ménage & nettoyage', 1330, 'expense', '#fbbf24', 1, 1, 'categoriesSeed.menage.services.nettoyage');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1332, 'Buanderie & pressing', 1330, 'expense', '#fcd34d', 1, 2, 'categoriesSeed.menage.services.buanderie');
|
||||
|
||||
-- 1400 — Vêtements & chaussures
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1410, 'Vêtements adultes', 1400, 'expense', '#d946ef', 1, 1, 'categoriesSeed.vetements.adultes');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1420, 'Vêtements enfants', 1400, 'expense', '#c026d3', 1, 2, 'categoriesSeed.vetements.enfants');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1430, 'Chaussures', 1400, 'expense', '#a21caf', 1, 3, 'categoriesSeed.vetements.chaussures');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1440, 'Accessoires & bijoux', 1400, 'expense', '#86198f', 1, 4, 'categoriesSeed.vetements.accessoires');
|
||||
|
||||
-- 1500 — Transport
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1510, 'Véhicule personnel', 1500, 'expense', '#2563eb', 0, 1, 'categoriesSeed.transport.vehicule.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1511, 'Achat / location véhicule', 1510, 'expense', '#2563eb', 1, 1, 'categoriesSeed.transport.vehicule.achat');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1512, 'Essence', 1510, 'expense', '#1d4ed8', 1, 2, 'categoriesSeed.transport.vehicule.essence');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1513, 'Entretien & réparations auto', 1510, 'expense', '#1e40af', 1, 3, 'categoriesSeed.transport.vehicule.entretien');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1514, 'Immatriculation & permis', 1510, 'expense', '#1e3a8a', 1, 4, 'categoriesSeed.transport.vehicule.immatriculation');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1515, 'Stationnement & péages', 1510, 'expense', '#3b82f6', 1, 5, 'categoriesSeed.transport.vehicule.stationnement');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1516, 'Assurance auto', 1510, 'expense', '#60a5fa', 1, 6, 'categoriesSeed.transport.vehicule.assurance');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1520, 'Transport public', 1500, 'expense', '#0ea5e9', 0, 2, 'categoriesSeed.transport.public.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1521, 'Autobus & métro', 1520, 'expense', '#0ea5e9', 1, 1, 'categoriesSeed.transport.public.autobus');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1522, 'Train de banlieue', 1520, 'expense', '#0284c7', 1, 2, 'categoriesSeed.transport.public.train');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1523, 'Taxi & covoiturage', 1520, 'expense', '#0369a1', 1, 3, 'categoriesSeed.transport.public.taxi');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1530, 'Voyages longue distance', 1500, 'expense', '#38bdf8', 0, 3, 'categoriesSeed.transport.voyages.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1531, 'Avion', 1530, 'expense', '#38bdf8', 1, 1, 'categoriesSeed.transport.voyages.avion');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1532, 'Train & autocar', 1530, 'expense', '#7dd3fc', 1, 2, 'categoriesSeed.transport.voyages.trainAutocar');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1533, 'Hébergement', 1530, 'expense', '#bae6fd', 1, 3, 'categoriesSeed.transport.voyages.hebergement');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1534, 'Location véhicule voyage', 1530, 'expense', '#e0f2fe', 1, 4, 'categoriesSeed.transport.voyages.location');
|
||||
|
||||
-- 1600 — Santé & soins personnels
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1610, 'Soins médicaux', 1600, 'expense', '#f43f5e', 0, 1, 'categoriesSeed.sante.medicaux.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1611, 'Pharmacie', 1610, 'expense', '#f43f5e', 1, 1, 'categoriesSeed.sante.medicaux.pharmacie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1612, 'Consultations médicales', 1610, 'expense', '#e11d48', 1, 2, 'categoriesSeed.sante.medicaux.consultations');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1613, 'Dentiste & orthodontiste', 1610, 'expense', '#be123c', 1, 3, 'categoriesSeed.sante.medicaux.dentiste');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1614, 'Optométrie & lunettes', 1610, 'expense', '#9f1239', 1, 4, 'categoriesSeed.sante.medicaux.optometrie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1615, 'Thérapies (physio, psycho, etc.)', 1610, 'expense', '#881337', 1, 5, 'categoriesSeed.sante.medicaux.therapies');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1616, 'Assurance santé complémentaire', 1610, 'expense', '#fb7185', 1, 6, 'categoriesSeed.sante.medicaux.assurance');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1620, 'Soins personnels', 1600, 'expense', '#fb7185', 0, 2, 'categoriesSeed.sante.personnels.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1621, 'Coiffure & esthétique', 1620, 'expense', '#fb7185', 1, 1, 'categoriesSeed.sante.personnels.coiffure');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1622, 'Produits de soins corporels', 1620, 'expense', '#fda4af', 1, 2, 'categoriesSeed.sante.personnels.soins');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1630, 'Assurance vie & invalidité', 1600, 'expense', '#14b8a6', 1, 3, 'categoriesSeed.sante.assuranceVie');
|
||||
|
||||
-- 1700 — Loisirs, formation & lecture
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1710, 'Divertissement', 1700, 'expense', '#8b5cf6', 0, 1, 'categoriesSeed.loisirs.divertissement.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1711, 'Cinéma & spectacles', 1710, 'expense', '#8b5cf6', 1, 1, 'categoriesSeed.loisirs.divertissement.cinema');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1712, 'Jeux vidéo & consoles', 1710, 'expense', '#a78bfa', 1, 2, 'categoriesSeed.loisirs.divertissement.jeux');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1713, 'Streaming vidéo', 1710, 'expense', '#c4b5fd', 1, 3, 'categoriesSeed.loisirs.divertissement.streamingVideo');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1714, 'Streaming musique & audio', 1710, 'expense', '#ddd6fe', 1, 4, 'categoriesSeed.loisirs.divertissement.streamingMusique');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1715, 'Jouets & passe-temps', 1710, 'expense', '#ede9fe', 1, 5, 'categoriesSeed.loisirs.divertissement.jouets');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1720, 'Sports & plein air', 1700, 'expense', '#22c55e', 0, 2, 'categoriesSeed.loisirs.sports.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1721, 'Abonnements sportifs', 1720, 'expense', '#22c55e', 1, 1, 'categoriesSeed.loisirs.sports.abonnements');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1722, 'Équipement sportif', 1720, 'expense', '#4ade80', 1, 2, 'categoriesSeed.loisirs.sports.equipement');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1723, 'Parcs & activités plein air', 1720, 'expense', '#86efac', 1, 3, 'categoriesSeed.loisirs.sports.parcs');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1730, 'Formation & éducation', 1700, 'expense', '#6366f1', 0, 3, 'categoriesSeed.loisirs.formation.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1731, 'Scolarité (frais)', 1730, 'expense', '#6366f1', 1, 1, 'categoriesSeed.loisirs.formation.scolarite');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1732, 'Matériel scolaire', 1730, 'expense', '#818cf8', 1, 2, 'categoriesSeed.loisirs.formation.materiel');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1733, 'Cours & certifications', 1730, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.loisirs.formation.cours');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1734, 'Abonnements professionnels', 1730, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.loisirs.formation.abonnements');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1740, 'Lecture & médias', 1700, 'expense', '#ec4899', 0, 4, 'categoriesSeed.loisirs.lecture.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1741, 'Livres', 1740, 'expense', '#ec4899', 1, 1, 'categoriesSeed.loisirs.lecture.livres');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1742, 'Journaux & magazines', 1740, 'expense', '#f472b6', 1, 2, 'categoriesSeed.loisirs.lecture.journaux');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1750, 'Animaux de compagnie', 1700, 'expense', '#a855f7', 0, 5, 'categoriesSeed.loisirs.animaux.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1751, 'Nourriture & accessoires animaux', 1750, 'expense', '#a855f7', 1, 1, 'categoriesSeed.loisirs.animaux.nourriture');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1752, 'Vétérinaire', 1750, 'expense', '#c084fc', 1, 2, 'categoriesSeed.loisirs.animaux.veterinaire');
|
||||
|
||||
-- 1800 — Boissons, tabac & cannabis
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1810, 'Alcool (SAQ, microbrasseries)', 1800, 'expense', '#7c3aed', 1, 1, 'categoriesSeed.consommation.alcool');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1820, 'Cannabis (SQDC)', 1800, 'expense', '#6d28d9', 1, 2, 'categoriesSeed.consommation.cannabis');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1830, 'Tabac', 1800, 'expense', '#5b21b6', 1, 3, 'categoriesSeed.consommation.tabac');
|
||||
|
||||
-- 1900 — Finances & obligations
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1910, 'Frais bancaires', 1900, 'expense', '#6b7280', 0, 1, 'categoriesSeed.finances.fraisBancaires.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1911, 'Frais de compte', 1910, 'expense', '#6b7280', 1, 1, 'categoriesSeed.finances.fraisBancaires.compte');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1912, 'Intérêts & frais de crédit', 1910, 'expense', '#9ca3af', 1, 2, 'categoriesSeed.finances.fraisBancaires.interets');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1913, 'Frais de change', 1910, 'expense', '#d1d5db', 1, 3, 'categoriesSeed.finances.fraisBancaires.change');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1920, 'Impôts & taxes', 1900, 'expense', '#dc2626', 0, 2, 'categoriesSeed.finances.impots.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1921, 'Impôt fédéral', 1920, 'expense', '#dc2626', 1, 1, 'categoriesSeed.finances.impots.federal');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1922, 'Impôt provincial', 1920, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.finances.impots.provincial');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1923, 'Acomptes provisionnels', 1920, 'expense', '#991b1b', 1, 3, 'categoriesSeed.finances.impots.acomptes');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1930, 'Dons & cotisations', 1900, 'expense', '#ec4899', 0, 3, 'categoriesSeed.finances.dons.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1931, 'Dons de charité', 1930, 'expense', '#ec4899', 1, 1, 'categoriesSeed.finances.dons.charite');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1932, 'Cotisations professionnelles', 1930, 'expense', '#f472b6', 1, 2, 'categoriesSeed.finances.dons.professionnelles');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1933, 'Cotisations syndicales', 1930, 'expense', '#f9a8d4', 1, 3, 'categoriesSeed.finances.dons.syndicales');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1940, 'Cadeaux', 1900, 'expense', '#f43f5e', 1, 4, 'categoriesSeed.finances.cadeaux');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1945, 'Retrait cash', 1900, 'expense', '#57534e', 1, 5, 'categoriesSeed.finances.cash');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1946, 'Achats divers non catégorisés', 1900, 'expense', '#78716c', 1, 6, 'categoriesSeed.finances.divers');
|
||||
|
||||
-- 1950 — Transferts & placements
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1960, 'Épargne & placements', 1950, 'transfer', '#0ea5e9', 0, 1, 'categoriesSeed.transferts.epargne.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1961, 'REER / RRSP', 1960, 'transfer', '#0ea5e9', 1, 1, 'categoriesSeed.transferts.epargne.reer');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1962, 'CELI / TFSA', 1960, 'transfer', '#0284c7', 1, 2, 'categoriesSeed.transferts.epargne.celi');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1963, 'REEE / RESP', 1960, 'transfer', '#0369a1', 1, 3, 'categoriesSeed.transferts.epargne.reee');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1964, 'Compte non-enregistré', 1960, 'transfer', '#075985', 1, 4, 'categoriesSeed.transferts.epargne.nonEnregistre');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1965, 'Fonds d''urgence', 1960, 'transfer', '#0c4a6e', 1, 5, 'categoriesSeed.transferts.epargne.urgence');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1970, 'Remboursement de dette', 1950, 'transfer', '#7c3aed', 0, 2, 'categoriesSeed.transferts.dette.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1971, 'Paiement carte crédit', 1970, 'transfer', '#7c3aed', 1, 1, 'categoriesSeed.transferts.dette.carteCredit');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1972, 'Remboursement prêt étudiant', 1970, 'transfer', '#8b5cf6', 1, 2, 'categoriesSeed.transferts.dette.etudiant');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1973, 'Remboursement prêt perso', 1970, 'transfer', '#a78bfa', 1, 3, 'categoriesSeed.transferts.dette.personnel');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1980, 'Transferts internes', 1950, 'transfer', '#64748b', 1, 3, 'categoriesSeed.transferts.internes');
|
||||
|
||||
-- ============================================================================
|
||||
-- Keywords — Canadian suppliers (150+ entries)
|
||||
-- ============================================================================
|
||||
|
||||
-- Alimentation > Épicerie régulière (1111)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('METRO', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('IGA', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MAXI', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUPER C', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LOBLAWS', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROVIGO', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ADONIS', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WHOLE FOODS', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AVRIL', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RACHELLE-BERY', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COSTCO', 1111, 50);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WALMART', 1111, 50);
|
||||
|
||||
-- Épicerie > Boucherie (1112)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCHERIE', 1112, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('POISSONNERIE', 1112, 0);
|
||||
|
||||
-- Épicerie > Boulangerie (1113)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOULANGERIE', 1113, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PATISSERIE', 1113, 0);
|
||||
|
||||
-- Épicerie > Dépanneur (1114)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD', 1114, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPANNEUR', 1114, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('7-ELEVEN', 1114, 0);
|
||||
|
||||
-- Restauration > Restaurant (1121)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RESTAURANT', 1121, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRASSERIE', 1121, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BISTRO', 1121, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUSHI', 1121, 0);
|
||||
|
||||
-- Restauration > Café (1122)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STARBUCKS', 1122, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIM HORTONS', 1122, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SECOND CUP', 1122, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VAN HOUTTE', 1122, 0);
|
||||
|
||||
-- Restauration > Fast food (1123)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MCDONALD', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUBWAY', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('A&W', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BURGER KING', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('KFC', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOMINOS', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PIZZA HUT', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELLE PROVINCE', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ST-HUBERT', 1123, 0);
|
||||
|
||||
-- Restauration > Livraison (1124)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOORDASH', 1124, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DD/DOORDASH', 1124, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER EATS', 1124, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SKIPTHEDISHES', 1124, 0);
|
||||
|
||||
-- Logement > Hypothèque (1212)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MTG/HYP', 1212, 0);
|
||||
|
||||
-- Logement > Taxes municipales (1213)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('M-ST-HILAIRE TX', 1213, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXES MUNICIPALES', 1213, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CSS PATRIOT', 1213, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXE SCOLAIRE', 1213, 0);
|
||||
|
||||
-- Électricité (1221)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO-QUEBEC', 1221, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO QUEBEC', 1221, 0);
|
||||
|
||||
-- Gaz (1222)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ENERGIR', 1222, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAZ METRO', 1222, 0);
|
||||
|
||||
-- Internet (1231)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIDEOTRON', 1231, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL INTERNET', 1231, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORICOM', 1231, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COGECO', 1231, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EBOX', 1231, 0);
|
||||
|
||||
-- Mobile (1232)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIZZ', 1232, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('KOODO', 1232, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PUBLIC MOBILE', 1232, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIRGIN', 1232, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL MOBILITE', 1232, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TELUS MOBILE', 1232, 0);
|
||||
|
||||
-- Entretien maison (1241)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('IKEA', 1241, 0);
|
||||
|
||||
-- Matériaux & outils (1243)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANADIAN TIRE', 1243, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANAC', 1243, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RONA', 1243, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HOME DEPOT', 1243, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BMR', 1243, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRINCESS AUTO', 1243, 0);
|
||||
|
||||
-- Assurance habitation (1250)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELAIR', 1250, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRYSM', 1250, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('INTACT ASSURANCE', 1250, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DESJARDINS ASSURANCE', 1250, 0);
|
||||
|
||||
-- Meubles (1311)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TANGUAY', 1311, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEON', 1311, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STRUCTUBE', 1311, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRICK', 1311, 0);
|
||||
|
||||
-- Électroménagers (1312)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BEST BUY', 1312, 0);
|
||||
|
||||
-- Décoration (1313)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCLAIR', 1313, 0);
|
||||
|
||||
-- Vêtements (1410)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIQLO', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WINNERS', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SIMONS', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARKS', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('H&M', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('OLD NAVY', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAP', 1410, 0);
|
||||
|
||||
-- Transport — Essence (1512)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SHELL', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ESSO', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ULTRAMAR', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO-CANADA', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO CANADA', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CREVIER', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HARNOIS', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD ESSENCE', 1512, 10);
|
||||
|
||||
-- Permis / SAAQ (1514)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAAQ', 1514, 0);
|
||||
|
||||
-- Transport public — autobus/métro (1521)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STM', 1521, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RTC', 1521, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STL', 1521, 0);
|
||||
|
||||
-- Train de banlieue (1522)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE MONT-SAINT', 1522, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE SAINT-HUBERT', 1522, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE CENTRALE', 1522, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EXO', 1522, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('REM', 1522, 0);
|
||||
|
||||
-- Taxi / Uber (1523)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER', 1523, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LYFT', 1523, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXI', 1523, 0);
|
||||
|
||||
-- Avion (1531)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR CANADA', 1531, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WESTJET', 1531, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR TRANSAT', 1531, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PORTER', 1531, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AEROPORTS DE MONTREAL', 1531, 0);
|
||||
|
||||
-- Hébergement (1533)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIRBNB', 1533, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HILTON', 1533, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARRIOTT', 1533, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOOKING.COM', 1533, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NORWEGIAN CRUISE', 1533, 0);
|
||||
|
||||
-- Pharmacie (1611)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('JEAN COUTU', 1611, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FAMILIPRIX', 1611, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHARMAPRIX', 1611, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROXIM', 1611, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIPRIX', 1611, 0);
|
||||
|
||||
-- Thérapies (1615)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHYSIOACTIF', 1615, 0);
|
||||
|
||||
-- Cinéma & spectacles (1711)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEPLEX', 1711, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEMA DU PARC', 1711, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TICKETMASTER', 1711, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CLUB SODA', 1711, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEPOINTDEVENTE', 1711, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EVENBRITE', 1711, 0);
|
||||
|
||||
-- Jeux vidéo (1712)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STEAMGAMES', 1712, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PLAYSTATION', 1712, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NINTENDO', 1712, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('XBOX', 1712, 0);
|
||||
|
||||
-- Streaming vidéo (1713)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NETFLIX', 1713, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRIMEVIDEO', 1713, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DISNEY PLUS', 1713, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRAVE', 1713, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE TV', 1713, 0);
|
||||
|
||||
-- Streaming musique (1714)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPOTIFY', 1714, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE MUSIC', 1714, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('YOUTUBE MUSIC', 1714, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIDAL', 1714, 0);
|
||||
|
||||
-- Jouets & hobbies (1715)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEGO', 1715, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TOYS R US', 1715, 0);
|
||||
|
||||
-- Équipement sportif (1722)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MOUNTAIN EQUIPMENT', 1722, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LA CORDEE', 1722, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DECATHLON', 1722, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPORTS EXPERTS', 1722, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ATMOSPHERE', 1722, 0);
|
||||
|
||||
-- Parcs & activités (1723)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SEPAQ', 1723, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BLOC SHOP', 1723, 0);
|
||||
|
||||
-- Lecture (1741)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RENAUD-BRAY', 1741, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('INDIGO', 1741, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARCHAMBAULT', 1741, 0);
|
||||
|
||||
-- Animaux — nourriture (1751)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MONDOU', 1751, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PET SMART', 1751, 0);
|
||||
|
||||
-- Alcool (1810)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAQ', 1810, 0);
|
||||
|
||||
-- Cannabis (1820)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SQDC', 1820, 0);
|
||||
|
||||
-- Frais bancaires (1911)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROGRAMME PERFORMANCE', 1911, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FRAIS MENSUELS', 1911, 0);
|
||||
|
||||
-- Impôts (1921, 1922)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GOUV. QUEBEC', 1922, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('REVENU QUEBEC', 1922, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARC IMPOT', 1921, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRA TAX', 1921, 0);
|
||||
|
||||
-- Dons de charité (1931)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('OXFAM', 1931, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CENTRAIDE', 1931, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FPA', 1931, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CROIX-ROUGE', 1931, 0);
|
||||
|
||||
-- Cotisations professionnelles (1932)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORDRE DES COMPTABL', 1932, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CPA CANADA', 1932, 0);
|
||||
|
||||
-- Cadeaux (1940)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DANS UN JARDIN', 1940, 0);
|
||||
|
||||
-- Divers (1946) — catch-all marketplace
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMAZON', 1946, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMZN', 1946, 0);
|
||||
|
||||
-- Placements (1964)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WS INVESTMENTS', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WEALTHSIMPLE', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PEAK INVESTMENT', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DYNAMIC FUND', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIDELITY', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AGF', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('QUESTRADE', 1964, 0);
|
||||
|
||||
-- Paie (1011)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAY/PAY', 1011, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPOT PAIE', 1011, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAYROLL', 1011, 0);
|
||||
184
src-tauri/src/database/fixtures/consolidated_v0.3.0.sql
Normal file
184
src-tauri/src/database/fixtures/consolidated_v0.3.0.sql
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
-- Consolidated schema for new profile databases
|
||||
-- This file bakes in the base schema + all migrations (v3-v6)
|
||||
-- Used ONLY for initializing new profile databases (not for the default profile)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_sources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
date_format TEXT NOT NULL DEFAULT '%d/%m/%Y',
|
||||
delimiter TEXT NOT NULL DEFAULT ';',
|
||||
encoding TEXT NOT NULL DEFAULT 'utf-8',
|
||||
column_mapping TEXT NOT NULL,
|
||||
skip_lines INTEGER NOT NULL DEFAULT 0,
|
||||
has_header INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS imported_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_id INTEGER NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
file_hash TEXT NOT NULL,
|
||||
import_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
row_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
notes TEXT,
|
||||
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE CASCADE,
|
||||
UNIQUE(source_id, filename)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
parent_id INTEGER,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'expense',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_inputable INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS suppliers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
normalized_name TEXT NOT NULL,
|
||||
category_id INTEGER,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS keywords (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
supplier_id INTEGER,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
|
||||
UNIQUE(keyword, category_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date DATE NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
category_id INTEGER,
|
||||
supplier_id INTEGER,
|
||||
source_id INTEGER,
|
||||
file_id INTEGER,
|
||||
original_description TEXT,
|
||||
notes TEXT,
|
||||
is_manually_categorized INTEGER NOT NULL DEFAULT 0,
|
||||
is_split INTEGER NOT NULL DEFAULT 0,
|
||||
parent_transaction_id INTEGER,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (file_id) REFERENCES imported_files(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (parent_transaction_id) REFERENCES transactions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS adjustments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
date DATE NOT NULL,
|
||||
is_recurring INTEGER NOT NULL DEFAULT 0,
|
||||
recurrence_rule TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS adjustment_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
adjustment_id INTEGER NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
description TEXT,
|
||||
FOREIGN KEY (adjustment_id) REFERENCES adjustments(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category_id INTEGER NOT NULL,
|
||||
year INTEGER NOT NULL,
|
||||
month INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
notes TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
|
||||
UNIQUE(category_id, year, month)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_template_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
template_id INTEGER NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
FOREIGN KEY (template_id) REFERENCES budget_templates(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
|
||||
UNIQUE(template_id, category_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_config_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
delimiter TEXT NOT NULL DEFAULT ';',
|
||||
encoding TEXT NOT NULL DEFAULT 'utf-8',
|
||||
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
|
||||
skip_lines INTEGER NOT NULL DEFAULT 0,
|
||||
has_header INTEGER NOT NULL DEFAULT 1,
|
||||
column_mapping TEXT NOT NULL,
|
||||
amount_mode TEXT NOT NULL DEFAULT 'single',
|
||||
sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_supplier ON transactions(supplier_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_source ON transactions(source_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_file ON transactions(file_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_parent ON transactions(parent_transaction_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_suppliers_category ON suppliers(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_suppliers_normalized ON suppliers(normalized_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_keywords_category ON keywords(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_keywords_keyword ON keywords(keyword);
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, month);
|
||||
CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id);
|
||||
|
||||
-- Default preferences
|
||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
|
||||
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 ('date_format', 'DD/MM/YYYY');
|
||||
657
src-tauri/src/database/fixtures/consolidated_v0.8.4.sql
Normal file
657
src-tauri/src/database/fixtures/consolidated_v0.8.4.sql
Normal file
|
|
@ -0,0 +1,657 @@
|
|||
-- Consolidated schema for new profile databases
|
||||
-- This file bakes in the base schema + all migrations (v3-v8) and pre-seeds
|
||||
-- the v1 IPC-aligned category taxonomy so that brand-new profiles immediately
|
||||
-- use the new standard. Existing profiles keep their v2 seed and are only
|
||||
-- tagged via the migration (categories_schema_version = 'v2').
|
||||
-- Used ONLY for initializing new profile databases (not for the default profile).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_sources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
date_format TEXT NOT NULL DEFAULT '%d/%m/%Y',
|
||||
delimiter TEXT NOT NULL DEFAULT ';',
|
||||
encoding TEXT NOT NULL DEFAULT 'utf-8',
|
||||
column_mapping TEXT NOT NULL,
|
||||
skip_lines INTEGER NOT NULL DEFAULT 0,
|
||||
has_header INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS imported_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_id INTEGER NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
file_hash TEXT NOT NULL,
|
||||
import_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
row_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
notes TEXT,
|
||||
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE CASCADE,
|
||||
UNIQUE(source_id, filename)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
parent_id INTEGER,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'expense',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_inputable INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
i18n_key TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS suppliers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
normalized_name TEXT NOT NULL,
|
||||
category_id INTEGER,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS keywords (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
supplier_id INTEGER,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
|
||||
UNIQUE(keyword, category_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date DATE NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
category_id INTEGER,
|
||||
supplier_id INTEGER,
|
||||
source_id INTEGER,
|
||||
file_id INTEGER,
|
||||
original_description TEXT,
|
||||
notes TEXT,
|
||||
is_manually_categorized INTEGER NOT NULL DEFAULT 0,
|
||||
is_split INTEGER NOT NULL DEFAULT 0,
|
||||
parent_transaction_id INTEGER,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (file_id) REFERENCES imported_files(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (parent_transaction_id) REFERENCES transactions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS adjustments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
date DATE NOT NULL,
|
||||
is_recurring INTEGER NOT NULL DEFAULT 0,
|
||||
recurrence_rule TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS adjustment_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
adjustment_id INTEGER NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
description TEXT,
|
||||
FOREIGN KEY (adjustment_id) REFERENCES adjustments(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category_id INTEGER NOT NULL,
|
||||
year INTEGER NOT NULL,
|
||||
month INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
notes TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
|
||||
UNIQUE(category_id, year, month)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_template_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
template_id INTEGER NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
FOREIGN KEY (template_id) REFERENCES budget_templates(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
|
||||
UNIQUE(template_id, category_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_config_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
delimiter TEXT NOT NULL DEFAULT ';',
|
||||
encoding TEXT NOT NULL DEFAULT 'utf-8',
|
||||
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
|
||||
skip_lines INTEGER NOT NULL DEFAULT 0,
|
||||
has_header INTEGER NOT NULL DEFAULT 1,
|
||||
column_mapping TEXT NOT NULL,
|
||||
amount_mode TEXT NOT NULL DEFAULT 'single',
|
||||
sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_supplier ON transactions(supplier_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_source ON transactions(source_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_file ON transactions(file_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_parent ON transactions(parent_transaction_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_suppliers_category ON suppliers(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_suppliers_normalized ON suppliers(normalized_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_keywords_category ON keywords(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_keywords_keyword ON keywords(keyword);
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, month);
|
||||
CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id);
|
||||
|
||||
-- Default preferences (new profiles ship with the v1 IPC taxonomy)
|
||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
|
||||
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 ('date_format', 'DD/MM/YYYY');
|
||||
INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v1');
|
||||
|
||||
-- ============================================================================
|
||||
-- Seed v1 — IPC Statistique Canada-aligned, 3 levels, Canada/Québec
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Reset any pre-existing category data (possible when tauri-plugin-sql runs
|
||||
-- the historical v2 seed migration on this fresh DB before this script
|
||||
-- executes). Keywords/categories are wiped and re-inserted with the v1 IDs
|
||||
-- (1000+ range) so existing references in migrations v3-v7 stay inert.
|
||||
-- ============================================================================
|
||||
DELETE FROM keywords;
|
||||
UPDATE transactions SET category_id = NULL;
|
||||
DELETE FROM categories;
|
||||
|
||||
-- LEVEL 1 — Roots (9 IPC components + Revenus + Transferts)
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1000, 'Revenus', NULL, 'income', '#16a34a', 0, 1, 'categoriesSeed.revenus.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1100, 'Alimentation', NULL, 'expense', '#ea580c', 0, 2, 'categoriesSeed.alimentation.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1200, 'Logement', NULL, 'expense', '#dc2626', 0, 3, 'categoriesSeed.logement.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1300, 'Ménage & ameublement', NULL, 'expense', '#ca8a04', 0, 4, 'categoriesSeed.menage.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1400, 'Vêtements & chaussures', NULL, 'expense', '#d946ef', 0, 5, 'categoriesSeed.vetements.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1500, 'Transport', NULL, 'expense', '#2563eb', 0, 6, 'categoriesSeed.transport.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1600, 'Santé & soins personnels', NULL, 'expense', '#f43f5e', 0, 7, 'categoriesSeed.sante.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1700, 'Loisirs, formation & lecture', NULL, 'expense', '#8b5cf6', 0, 8, 'categoriesSeed.loisirs.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1800, 'Boissons, tabac & cannabis', NULL, 'expense', '#7c3aed', 0, 9, 'categoriesSeed.consommation.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1900, 'Finances & obligations', NULL, 'expense', '#6b7280', 0, 10, 'categoriesSeed.finances.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1950, 'Transferts & placements', NULL, 'transfer', '#0ea5e9', 0, 11, 'categoriesSeed.transferts.root');
|
||||
|
||||
-- 1000 — Revenus
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1010, 'Emploi', 1000, 'income', '#22c55e', 0, 1, 'categoriesSeed.revenus.emploi.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1011, 'Paie régulière', 1010, 'income', '#22c55e', 1, 1, 'categoriesSeed.revenus.emploi.paie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1012, 'Primes & bonus', 1010, 'income', '#4ade80', 1, 2, 'categoriesSeed.revenus.emploi.primes');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1013, 'Travail autonome', 1010, 'income', '#86efac', 1, 3, 'categoriesSeed.revenus.emploi.autonome');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1020, 'Gouvernemental', 1000, 'income', '#16a34a', 0, 2, 'categoriesSeed.revenus.gouvernemental.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1021, 'Remboursement impôt', 1020, 'income', '#16a34a', 1, 1, 'categoriesSeed.revenus.gouvernemental.remboursementImpot');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1022, 'Allocations familiales', 1020, 'income', '#15803d', 1, 2, 'categoriesSeed.revenus.gouvernemental.allocations');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1023, 'Crédits TPS/TVQ', 1020, 'income', '#166534', 1, 3, 'categoriesSeed.revenus.gouvernemental.credits');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1024, 'Assurance-emploi / RQAP', 1020, 'income', '#14532d', 1, 4, 'categoriesSeed.revenus.gouvernemental.assuranceEmploi');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1030, 'Revenus de placement', 1000, 'income', '#10b981', 0, 3, 'categoriesSeed.revenus.placement.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1031, 'Intérêts & dividendes', 1030, 'income', '#10b981', 1, 1, 'categoriesSeed.revenus.placement.interets');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1032, 'Gains en capital', 1030, 'income', '#059669', 1, 2, 'categoriesSeed.revenus.placement.capital');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1033, 'Revenus locatifs', 1030, 'income', '#047857', 1, 3, 'categoriesSeed.revenus.placement.locatifs');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1090, 'Autres revenus', 1000, 'income', '#84cc16', 1, 9, 'categoriesSeed.revenus.autres');
|
||||
|
||||
-- 1100 — Alimentation
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1110, 'Épicerie & marché', 1100, 'expense', '#ea580c', 0, 1, 'categoriesSeed.alimentation.epicerie.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1111, 'Épicerie régulière', 1110, 'expense', '#ea580c', 1, 1, 'categoriesSeed.alimentation.epicerie.reguliere');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1112, 'Boucherie & poissonnerie', 1110, 'expense', '#c2410c', 1, 2, 'categoriesSeed.alimentation.epicerie.boucherie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1113, 'Boulangerie & pâtisserie', 1110, 'expense', '#9a3412', 1, 3, 'categoriesSeed.alimentation.epicerie.boulangerie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1114, 'Dépanneur', 1110, 'expense', '#7c2d12', 1, 4, 'categoriesSeed.alimentation.epicerie.depanneur');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1115, 'Marché & produits spécialisés', 1110, 'expense', '#fb923c', 1, 5, 'categoriesSeed.alimentation.epicerie.marche');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1120, 'Restauration', 1100, 'expense', '#f97316', 0, 2, 'categoriesSeed.alimentation.restauration.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1121, 'Restaurant', 1120, 'expense', '#f97316', 1, 1, 'categoriesSeed.alimentation.restauration.restaurant');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1122, 'Café & boulangerie rapide', 1120, 'expense', '#fb923c', 1, 2, 'categoriesSeed.alimentation.restauration.cafe');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1123, 'Restauration rapide', 1120, 'expense', '#fdba74', 1, 3, 'categoriesSeed.alimentation.restauration.fastfood');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1124, 'Livraison à domicile', 1120, 'expense', '#fed7aa', 1, 4, 'categoriesSeed.alimentation.restauration.livraison');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1125, 'Cantine & cafétéria', 1120, 'expense', '#ffedd5', 1, 5, 'categoriesSeed.alimentation.restauration.cantine');
|
||||
|
||||
-- 1200 — Logement
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1210, 'Habitation principale', 1200, 'expense', '#dc2626', 0, 1, 'categoriesSeed.logement.habitation.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1211, 'Loyer', 1210, 'expense', '#dc2626', 1, 1, 'categoriesSeed.logement.habitation.loyer');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1212, 'Hypothèque', 1210, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.logement.habitation.hypotheque');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1213, 'Taxes municipales & scolaires', 1210, 'expense', '#991b1b', 1, 3, 'categoriesSeed.logement.habitation.taxes');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1214, 'Charges de copropriété', 1210, 'expense', '#7f1d1d', 1, 4, 'categoriesSeed.logement.habitation.copropriete');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1220, 'Services publics', 1200, 'expense', '#ef4444', 0, 2, 'categoriesSeed.logement.services.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1221, 'Électricité', 1220, 'expense', '#ef4444', 1, 1, 'categoriesSeed.logement.services.electricite');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1222, 'Gaz naturel', 1220, 'expense', '#f87171', 1, 2, 'categoriesSeed.logement.services.gaz');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1223, 'Chauffage (mazout, propane)', 1220, 'expense', '#fca5a5', 1, 3, 'categoriesSeed.logement.services.chauffage');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1224, 'Eau & égouts', 1220, 'expense', '#fecaca', 1, 4, 'categoriesSeed.logement.services.eau');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1230, 'Communications', 1200, 'expense', '#6366f1', 0, 3, 'categoriesSeed.logement.communications.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1231, 'Internet résidentiel', 1230, 'expense', '#6366f1', 1, 1, 'categoriesSeed.logement.communications.internet');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1232, 'Téléphonie mobile', 1230, 'expense', '#818cf8', 1, 2, 'categoriesSeed.logement.communications.mobile');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1233, 'Téléphonie résidentielle', 1230, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.logement.communications.residentielle');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1234, 'Câblodistribution & streaming TV', 1230, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.logement.communications.streamingTv');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1240, 'Entretien & réparations', 1200, 'expense', '#e11d48', 0, 4, 'categoriesSeed.logement.entretien.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1241, 'Entretien général', 1240, 'expense', '#e11d48', 1, 1, 'categoriesSeed.logement.entretien.general');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1242, 'Rénovations', 1240, 'expense', '#be123c', 1, 2, 'categoriesSeed.logement.entretien.renovations');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1243, 'Matériaux & outils', 1240, 'expense', '#9f1239', 1, 3, 'categoriesSeed.logement.entretien.materiaux');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1244, 'Aménagement paysager', 1240, 'expense', '#881337', 1, 4, 'categoriesSeed.logement.entretien.paysager');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1250, 'Assurance habitation', 1200, 'expense', '#14b8a6', 1, 5, 'categoriesSeed.logement.assurance');
|
||||
|
||||
-- 1300 — Ménage & ameublement
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1310, 'Ameublement', 1300, 'expense', '#ca8a04', 0, 1, 'categoriesSeed.menage.ameublement.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1311, 'Meubles', 1310, 'expense', '#ca8a04', 1, 1, 'categoriesSeed.menage.ameublement.meubles');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1312, 'Électroménagers', 1310, 'expense', '#a16207', 1, 2, 'categoriesSeed.menage.ameublement.electromenagers');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1313, 'Décoration', 1310, 'expense', '#854d0e', 1, 3, 'categoriesSeed.menage.ameublement.decoration');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1320, 'Fournitures ménagères', 1300, 'expense', '#eab308', 0, 2, 'categoriesSeed.menage.fournitures.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1321, 'Produits d''entretien', 1320, 'expense', '#eab308', 1, 1, 'categoriesSeed.menage.fournitures.entretien');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1322, 'Literie & linge de maison', 1320, 'expense', '#facc15', 1, 2, 'categoriesSeed.menage.fournitures.literie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1323, 'Vaisselle & ustensiles', 1320, 'expense', '#fde047', 1, 3, 'categoriesSeed.menage.fournitures.vaisselle');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1330, 'Services domestiques', 1300, 'expense', '#fbbf24', 0, 3, 'categoriesSeed.menage.services.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1331, 'Ménage & nettoyage', 1330, 'expense', '#fbbf24', 1, 1, 'categoriesSeed.menage.services.nettoyage');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1332, 'Buanderie & pressing', 1330, 'expense', '#fcd34d', 1, 2, 'categoriesSeed.menage.services.buanderie');
|
||||
|
||||
-- 1400 — Vêtements & chaussures
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1410, 'Vêtements adultes', 1400, 'expense', '#d946ef', 1, 1, 'categoriesSeed.vetements.adultes');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1420, 'Vêtements enfants', 1400, 'expense', '#c026d3', 1, 2, 'categoriesSeed.vetements.enfants');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1430, 'Chaussures', 1400, 'expense', '#a21caf', 1, 3, 'categoriesSeed.vetements.chaussures');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1440, 'Accessoires & bijoux', 1400, 'expense', '#86198f', 1, 4, 'categoriesSeed.vetements.accessoires');
|
||||
|
||||
-- 1500 — Transport
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1510, 'Véhicule personnel', 1500, 'expense', '#2563eb', 0, 1, 'categoriesSeed.transport.vehicule.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1511, 'Achat / location véhicule', 1510, 'expense', '#2563eb', 1, 1, 'categoriesSeed.transport.vehicule.achat');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1512, 'Essence', 1510, 'expense', '#1d4ed8', 1, 2, 'categoriesSeed.transport.vehicule.essence');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1513, 'Entretien & réparations auto', 1510, 'expense', '#1e40af', 1, 3, 'categoriesSeed.transport.vehicule.entretien');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1514, 'Immatriculation & permis', 1510, 'expense', '#1e3a8a', 1, 4, 'categoriesSeed.transport.vehicule.immatriculation');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1515, 'Stationnement & péages', 1510, 'expense', '#3b82f6', 1, 5, 'categoriesSeed.transport.vehicule.stationnement');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1516, 'Assurance auto', 1510, 'expense', '#60a5fa', 1, 6, 'categoriesSeed.transport.vehicule.assurance');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1520, 'Transport public', 1500, 'expense', '#0ea5e9', 0, 2, 'categoriesSeed.transport.public.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1521, 'Autobus & métro', 1520, 'expense', '#0ea5e9', 1, 1, 'categoriesSeed.transport.public.autobus');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1522, 'Train de banlieue', 1520, 'expense', '#0284c7', 1, 2, 'categoriesSeed.transport.public.train');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1523, 'Taxi & covoiturage', 1520, 'expense', '#0369a1', 1, 3, 'categoriesSeed.transport.public.taxi');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1530, 'Voyages longue distance', 1500, 'expense', '#38bdf8', 0, 3, 'categoriesSeed.transport.voyages.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1531, 'Avion', 1530, 'expense', '#38bdf8', 1, 1, 'categoriesSeed.transport.voyages.avion');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1532, 'Train & autocar', 1530, 'expense', '#7dd3fc', 1, 2, 'categoriesSeed.transport.voyages.trainAutocar');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1533, 'Hébergement', 1530, 'expense', '#bae6fd', 1, 3, 'categoriesSeed.transport.voyages.hebergement');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1534, 'Location véhicule voyage', 1530, 'expense', '#e0f2fe', 1, 4, 'categoriesSeed.transport.voyages.location');
|
||||
|
||||
-- 1600 — Santé & soins personnels
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1610, 'Soins médicaux', 1600, 'expense', '#f43f5e', 0, 1, 'categoriesSeed.sante.medicaux.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1611, 'Pharmacie', 1610, 'expense', '#f43f5e', 1, 1, 'categoriesSeed.sante.medicaux.pharmacie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1612, 'Consultations médicales', 1610, 'expense', '#e11d48', 1, 2, 'categoriesSeed.sante.medicaux.consultations');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1613, 'Dentiste & orthodontiste', 1610, 'expense', '#be123c', 1, 3, 'categoriesSeed.sante.medicaux.dentiste');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1614, 'Optométrie & lunettes', 1610, 'expense', '#9f1239', 1, 4, 'categoriesSeed.sante.medicaux.optometrie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1615, 'Thérapies (physio, psycho, etc.)', 1610, 'expense', '#881337', 1, 5, 'categoriesSeed.sante.medicaux.therapies');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1616, 'Assurance santé complémentaire', 1610, 'expense', '#fb7185', 1, 6, 'categoriesSeed.sante.medicaux.assurance');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1620, 'Soins personnels', 1600, 'expense', '#fb7185', 0, 2, 'categoriesSeed.sante.personnels.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1621, 'Coiffure & esthétique', 1620, 'expense', '#fb7185', 1, 1, 'categoriesSeed.sante.personnels.coiffure');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1622, 'Produits de soins corporels', 1620, 'expense', '#fda4af', 1, 2, 'categoriesSeed.sante.personnels.soins');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1630, 'Assurance vie & invalidité', 1600, 'expense', '#14b8a6', 1, 3, 'categoriesSeed.sante.assuranceVie');
|
||||
|
||||
-- 1700 — Loisirs, formation & lecture
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1710, 'Divertissement', 1700, 'expense', '#8b5cf6', 0, 1, 'categoriesSeed.loisirs.divertissement.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1711, 'Cinéma & spectacles', 1710, 'expense', '#8b5cf6', 1, 1, 'categoriesSeed.loisirs.divertissement.cinema');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1712, 'Jeux vidéo & consoles', 1710, 'expense', '#a78bfa', 1, 2, 'categoriesSeed.loisirs.divertissement.jeux');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1713, 'Streaming vidéo', 1710, 'expense', '#c4b5fd', 1, 3, 'categoriesSeed.loisirs.divertissement.streamingVideo');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1714, 'Streaming musique & audio', 1710, 'expense', '#ddd6fe', 1, 4, 'categoriesSeed.loisirs.divertissement.streamingMusique');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1715, 'Jouets & passe-temps', 1710, 'expense', '#ede9fe', 1, 5, 'categoriesSeed.loisirs.divertissement.jouets');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1720, 'Sports & plein air', 1700, 'expense', '#22c55e', 0, 2, 'categoriesSeed.loisirs.sports.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1721, 'Abonnements sportifs', 1720, 'expense', '#22c55e', 1, 1, 'categoriesSeed.loisirs.sports.abonnements');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1722, 'Équipement sportif', 1720, 'expense', '#4ade80', 1, 2, 'categoriesSeed.loisirs.sports.equipement');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1723, 'Parcs & activités plein air', 1720, 'expense', '#86efac', 1, 3, 'categoriesSeed.loisirs.sports.parcs');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1730, 'Formation & éducation', 1700, 'expense', '#6366f1', 0, 3, 'categoriesSeed.loisirs.formation.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1731, 'Scolarité (frais)', 1730, 'expense', '#6366f1', 1, 1, 'categoriesSeed.loisirs.formation.scolarite');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1732, 'Matériel scolaire', 1730, 'expense', '#818cf8', 1, 2, 'categoriesSeed.loisirs.formation.materiel');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1733, 'Cours & certifications', 1730, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.loisirs.formation.cours');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1734, 'Abonnements professionnels', 1730, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.loisirs.formation.abonnements');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1740, 'Lecture & médias', 1700, 'expense', '#ec4899', 0, 4, 'categoriesSeed.loisirs.lecture.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1741, 'Livres', 1740, 'expense', '#ec4899', 1, 1, 'categoriesSeed.loisirs.lecture.livres');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1742, 'Journaux & magazines', 1740, 'expense', '#f472b6', 1, 2, 'categoriesSeed.loisirs.lecture.journaux');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1750, 'Animaux de compagnie', 1700, 'expense', '#a855f7', 0, 5, 'categoriesSeed.loisirs.animaux.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1751, 'Nourriture & accessoires animaux', 1750, 'expense', '#a855f7', 1, 1, 'categoriesSeed.loisirs.animaux.nourriture');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1752, 'Vétérinaire', 1750, 'expense', '#c084fc', 1, 2, 'categoriesSeed.loisirs.animaux.veterinaire');
|
||||
|
||||
-- 1800 — Boissons, tabac & cannabis
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1810, 'Alcool (SAQ, microbrasseries)', 1800, 'expense', '#7c3aed', 1, 1, 'categoriesSeed.consommation.alcool');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1820, 'Cannabis (SQDC)', 1800, 'expense', '#6d28d9', 1, 2, 'categoriesSeed.consommation.cannabis');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1830, 'Tabac', 1800, 'expense', '#5b21b6', 1, 3, 'categoriesSeed.consommation.tabac');
|
||||
|
||||
-- 1900 — Finances & obligations
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1910, 'Frais bancaires', 1900, 'expense', '#6b7280', 0, 1, 'categoriesSeed.finances.fraisBancaires.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1911, 'Frais de compte', 1910, 'expense', '#6b7280', 1, 1, 'categoriesSeed.finances.fraisBancaires.compte');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1912, 'Intérêts & frais de crédit', 1910, 'expense', '#9ca3af', 1, 2, 'categoriesSeed.finances.fraisBancaires.interets');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1913, 'Frais de change', 1910, 'expense', '#d1d5db', 1, 3, 'categoriesSeed.finances.fraisBancaires.change');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1920, 'Impôts & taxes', 1900, 'expense', '#dc2626', 0, 2, 'categoriesSeed.finances.impots.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1921, 'Impôt fédéral', 1920, 'expense', '#dc2626', 1, 1, 'categoriesSeed.finances.impots.federal');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1922, 'Impôt provincial', 1920, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.finances.impots.provincial');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1923, 'Acomptes provisionnels', 1920, 'expense', '#991b1b', 1, 3, 'categoriesSeed.finances.impots.acomptes');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1930, 'Dons & cotisations', 1900, 'expense', '#ec4899', 0, 3, 'categoriesSeed.finances.dons.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1931, 'Dons de charité', 1930, 'expense', '#ec4899', 1, 1, 'categoriesSeed.finances.dons.charite');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1932, 'Cotisations professionnelles', 1930, 'expense', '#f472b6', 1, 2, 'categoriesSeed.finances.dons.professionnelles');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1933, 'Cotisations syndicales', 1930, 'expense', '#f9a8d4', 1, 3, 'categoriesSeed.finances.dons.syndicales');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1940, 'Cadeaux', 1900, 'expense', '#f43f5e', 1, 4, 'categoriesSeed.finances.cadeaux');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1945, 'Retrait cash', 1900, 'expense', '#57534e', 1, 5, 'categoriesSeed.finances.cash');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1946, 'Achats divers non catégorisés', 1900, 'expense', '#78716c', 1, 6, 'categoriesSeed.finances.divers');
|
||||
|
||||
-- 1950 — Transferts & placements
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1960, 'Épargne & placements', 1950, 'transfer', '#0ea5e9', 0, 1, 'categoriesSeed.transferts.epargne.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1961, 'REER / RRSP', 1960, 'transfer', '#0ea5e9', 1, 1, 'categoriesSeed.transferts.epargne.reer');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1962, 'CELI / TFSA', 1960, 'transfer', '#0284c7', 1, 2, 'categoriesSeed.transferts.epargne.celi');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1963, 'REEE / RESP', 1960, 'transfer', '#0369a1', 1, 3, 'categoriesSeed.transferts.epargne.reee');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1964, 'Compte non-enregistré', 1960, 'transfer', '#075985', 1, 4, 'categoriesSeed.transferts.epargne.nonEnregistre');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1965, 'Fonds d''urgence', 1960, 'transfer', '#0c4a6e', 1, 5, 'categoriesSeed.transferts.epargne.urgence');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1970, 'Remboursement de dette', 1950, 'transfer', '#7c3aed', 0, 2, 'categoriesSeed.transferts.dette.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1971, 'Paiement carte crédit', 1970, 'transfer', '#7c3aed', 1, 1, 'categoriesSeed.transferts.dette.carteCredit');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1972, 'Remboursement prêt étudiant', 1970, 'transfer', '#8b5cf6', 1, 2, 'categoriesSeed.transferts.dette.etudiant');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1973, 'Remboursement prêt perso', 1970, 'transfer', '#a78bfa', 1, 3, 'categoriesSeed.transferts.dette.personnel');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1980, 'Transferts internes', 1950, 'transfer', '#64748b', 1, 3, 'categoriesSeed.transferts.internes');
|
||||
|
||||
-- ============================================================================
|
||||
-- Keywords — Canadian suppliers (150+ entries)
|
||||
-- ============================================================================
|
||||
|
||||
-- Alimentation > Épicerie régulière (1111)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('METRO', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('IGA', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MAXI', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUPER C', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LOBLAWS', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROVIGO', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ADONIS', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WHOLE FOODS', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AVRIL', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RACHELLE-BERY', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COSTCO', 1111, 50);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WALMART', 1111, 50);
|
||||
|
||||
-- Épicerie > Boucherie (1112)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCHERIE', 1112, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('POISSONNERIE', 1112, 0);
|
||||
|
||||
-- Épicerie > Boulangerie (1113)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOULANGERIE', 1113, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PATISSERIE', 1113, 0);
|
||||
|
||||
-- Épicerie > Dépanneur (1114)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD', 1114, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPANNEUR', 1114, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('7-ELEVEN', 1114, 0);
|
||||
|
||||
-- Restauration > Restaurant (1121)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RESTAURANT', 1121, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRASSERIE', 1121, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BISTRO', 1121, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUSHI', 1121, 0);
|
||||
|
||||
-- Restauration > Café (1122)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STARBUCKS', 1122, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIM HORTONS', 1122, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SECOND CUP', 1122, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VAN HOUTTE', 1122, 0);
|
||||
|
||||
-- Restauration > Fast food (1123)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MCDONALD', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUBWAY', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('A&W', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BURGER KING', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('KFC', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOMINOS', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PIZZA HUT', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELLE PROVINCE', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ST-HUBERT', 1123, 0);
|
||||
|
||||
-- Restauration > Livraison (1124)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOORDASH', 1124, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DD/DOORDASH', 1124, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER EATS', 1124, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SKIPTHEDISHES', 1124, 0);
|
||||
|
||||
-- Logement > Hypothèque (1212)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MTG/HYP', 1212, 0);
|
||||
|
||||
-- Logement > Taxes municipales (1213)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('M-ST-HILAIRE TX', 1213, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXES MUNICIPALES', 1213, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CSS PATRIOT', 1213, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXE SCOLAIRE', 1213, 0);
|
||||
|
||||
-- Électricité (1221)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO-QUEBEC', 1221, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO QUEBEC', 1221, 0);
|
||||
|
||||
-- Gaz (1222)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ENERGIR', 1222, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAZ METRO', 1222, 0);
|
||||
|
||||
-- Internet (1231)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIDEOTRON', 1231, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL INTERNET', 1231, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORICOM', 1231, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COGECO', 1231, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EBOX', 1231, 0);
|
||||
|
||||
-- Mobile (1232)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIZZ', 1232, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('KOODO', 1232, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PUBLIC MOBILE', 1232, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIRGIN', 1232, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL MOBILITE', 1232, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TELUS MOBILE', 1232, 0);
|
||||
|
||||
-- Entretien maison (1241)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('IKEA', 1241, 0);
|
||||
|
||||
-- Matériaux & outils (1243)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANADIAN TIRE', 1243, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANAC', 1243, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RONA', 1243, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HOME DEPOT', 1243, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BMR', 1243, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRINCESS AUTO', 1243, 0);
|
||||
|
||||
-- Assurance habitation (1250)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELAIR', 1250, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRYSM', 1250, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('INTACT ASSURANCE', 1250, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DESJARDINS ASSURANCE', 1250, 0);
|
||||
|
||||
-- Meubles (1311)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TANGUAY', 1311, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEON', 1311, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STRUCTUBE', 1311, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRICK', 1311, 0);
|
||||
|
||||
-- Électroménagers (1312)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BEST BUY', 1312, 0);
|
||||
|
||||
-- Décoration (1313)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCLAIR', 1313, 0);
|
||||
|
||||
-- Vêtements (1410)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIQLO', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WINNERS', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SIMONS', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARKS', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('H&M', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('OLD NAVY', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAP', 1410, 0);
|
||||
|
||||
-- Transport — Essence (1512)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SHELL', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ESSO', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ULTRAMAR', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO-CANADA', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO CANADA', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CREVIER', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HARNOIS', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD ESSENCE', 1512, 10);
|
||||
|
||||
-- Permis / SAAQ (1514)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAAQ', 1514, 0);
|
||||
|
||||
-- Transport public — autobus/métro (1521)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STM', 1521, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RTC', 1521, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STL', 1521, 0);
|
||||
|
||||
-- Train de banlieue (1522)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE MONT-SAINT', 1522, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE SAINT-HUBERT', 1522, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE CENTRALE', 1522, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EXO', 1522, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('REM', 1522, 0);
|
||||
|
||||
-- Taxi / Uber (1523)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER', 1523, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LYFT', 1523, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXI', 1523, 0);
|
||||
|
||||
-- Avion (1531)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR CANADA', 1531, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WESTJET', 1531, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR TRANSAT', 1531, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PORTER', 1531, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AEROPORTS DE MONTREAL', 1531, 0);
|
||||
|
||||
-- Hébergement (1533)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIRBNB', 1533, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HILTON', 1533, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARRIOTT', 1533, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOOKING.COM', 1533, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NORWEGIAN CRUISE', 1533, 0);
|
||||
|
||||
-- Pharmacie (1611)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('JEAN COUTU', 1611, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FAMILIPRIX', 1611, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHARMAPRIX', 1611, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROXIM', 1611, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIPRIX', 1611, 0);
|
||||
|
||||
-- Thérapies (1615)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHYSIOACTIF', 1615, 0);
|
||||
|
||||
-- Cinéma & spectacles (1711)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEPLEX', 1711, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEMA DU PARC', 1711, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TICKETMASTER', 1711, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CLUB SODA', 1711, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEPOINTDEVENTE', 1711, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EVENBRITE', 1711, 0);
|
||||
|
||||
-- Jeux vidéo (1712)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STEAMGAMES', 1712, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PLAYSTATION', 1712, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NINTENDO', 1712, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('XBOX', 1712, 0);
|
||||
|
||||
-- Streaming vidéo (1713)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NETFLIX', 1713, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRIMEVIDEO', 1713, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DISNEY PLUS', 1713, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRAVE', 1713, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE TV', 1713, 0);
|
||||
|
||||
-- Streaming musique (1714)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPOTIFY', 1714, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE MUSIC', 1714, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('YOUTUBE MUSIC', 1714, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIDAL', 1714, 0);
|
||||
|
||||
-- Jouets & hobbies (1715)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEGO', 1715, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TOYS R US', 1715, 0);
|
||||
|
||||
-- Équipement sportif (1722)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MOUNTAIN EQUIPMENT', 1722, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LA CORDEE', 1722, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DECATHLON', 1722, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPORTS EXPERTS', 1722, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ATMOSPHERE', 1722, 0);
|
||||
|
||||
-- Parcs & activités (1723)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SEPAQ', 1723, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BLOC SHOP', 1723, 0);
|
||||
|
||||
-- Lecture (1741)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RENAUD-BRAY', 1741, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('INDIGO', 1741, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARCHAMBAULT', 1741, 0);
|
||||
|
||||
-- Animaux — nourriture (1751)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MONDOU', 1751, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PET SMART', 1751, 0);
|
||||
|
||||
-- Alcool (1810)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAQ', 1810, 0);
|
||||
|
||||
-- Cannabis (1820)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SQDC', 1820, 0);
|
||||
|
||||
-- Frais bancaires (1911)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROGRAMME PERFORMANCE', 1911, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FRAIS MENSUELS', 1911, 0);
|
||||
|
||||
-- Impôts (1921, 1922)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GOUV. QUEBEC', 1922, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('REVENU QUEBEC', 1922, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARC IMPOT', 1921, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRA TAX', 1921, 0);
|
||||
|
||||
-- Dons de charité (1931)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('OXFAM', 1931, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CENTRAIDE', 1931, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FPA', 1931, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CROIX-ROUGE', 1931, 0);
|
||||
|
||||
-- Cotisations professionnelles (1932)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORDRE DES COMPTABL', 1932, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CPA CANADA', 1932, 0);
|
||||
|
||||
-- Cadeaux (1940)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DANS UN JARDIN', 1940, 0);
|
||||
|
||||
-- Divers (1946) — catch-all marketplace
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMAZON', 1946, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMZN', 1946, 0);
|
||||
|
||||
-- Placements (1964)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WS INVESTMENTS', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WEALTHSIMPLE', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PEAK INVESTMENT', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DYNAMIC FUND', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIDELITY', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AGF', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('QUESTRADE', 1964, 0);
|
||||
|
||||
-- Paie (1011)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAY/PAY', 1011, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPOT PAIE', 1011, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAYROLL', 1011, 0);
|
||||
747
src-tauri/src/database/fixtures/consolidated_v0.9.0.sql
Normal file
747
src-tauri/src/database/fixtures/consolidated_v0.9.0.sql
Normal file
|
|
@ -0,0 +1,747 @@
|
|||
-- Consolidated schema for new profile databases
|
||||
-- This file bakes in the base schema + all migrations (v3-v8) and pre-seeds
|
||||
-- the v1 IPC-aligned category taxonomy so that brand-new profiles immediately
|
||||
-- use the new standard. Existing profiles keep their v2 seed and are only
|
||||
-- tagged via the migration (categories_schema_version = 'v2').
|
||||
-- Used ONLY for initializing new profile databases (not for the default profile).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_sources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
date_format TEXT NOT NULL DEFAULT '%d/%m/%Y',
|
||||
delimiter TEXT NOT NULL DEFAULT ';',
|
||||
encoding TEXT NOT NULL DEFAULT 'utf-8',
|
||||
column_mapping TEXT NOT NULL,
|
||||
skip_lines INTEGER NOT NULL DEFAULT 0,
|
||||
has_header INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS imported_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_id INTEGER NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
file_hash TEXT NOT NULL,
|
||||
import_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
row_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
notes TEXT,
|
||||
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE CASCADE,
|
||||
UNIQUE(source_id, filename)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
parent_id INTEGER,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'expense',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_inputable INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
i18n_key TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS suppliers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
normalized_name TEXT NOT NULL,
|
||||
category_id INTEGER,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS keywords (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
supplier_id INTEGER,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
|
||||
UNIQUE(keyword, category_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date DATE NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
category_id INTEGER,
|
||||
supplier_id INTEGER,
|
||||
source_id INTEGER,
|
||||
file_id INTEGER,
|
||||
original_description TEXT,
|
||||
notes TEXT,
|
||||
is_manually_categorized INTEGER NOT NULL DEFAULT 0,
|
||||
is_split INTEGER NOT NULL DEFAULT 0,
|
||||
parent_transaction_id INTEGER,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (file_id) REFERENCES imported_files(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (parent_transaction_id) REFERENCES transactions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS adjustments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
date DATE NOT NULL,
|
||||
is_recurring INTEGER NOT NULL DEFAULT 0,
|
||||
recurrence_rule TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS adjustment_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
adjustment_id INTEGER NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
description TEXT,
|
||||
FOREIGN KEY (adjustment_id) REFERENCES adjustments(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category_id INTEGER NOT NULL,
|
||||
year INTEGER NOT NULL,
|
||||
month INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
notes TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
|
||||
UNIQUE(category_id, year, month)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_template_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
template_id INTEGER NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
FOREIGN KEY (template_id) REFERENCES budget_templates(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
|
||||
UNIQUE(template_id, category_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_config_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
delimiter TEXT NOT NULL DEFAULT ';',
|
||||
encoding TEXT NOT NULL DEFAULT 'utf-8',
|
||||
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
|
||||
skip_lines INTEGER NOT NULL DEFAULT 0,
|
||||
has_header INTEGER NOT NULL DEFAULT 1,
|
||||
column_mapping TEXT NOT NULL,
|
||||
amount_mode TEXT NOT NULL DEFAULT 'single',
|
||||
sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_supplier ON transactions(supplier_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_source ON transactions(source_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_file ON transactions(file_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_parent ON transactions(parent_transaction_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_suppliers_category ON suppliers(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_suppliers_normalized ON suppliers(normalized_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_keywords_category ON keywords(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_keywords_keyword ON keywords(keyword);
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, month);
|
||||
CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- Balance sheet (Bilan) — Migration v9 mirror for new profiles
|
||||
-- ============================================================================
|
||||
-- 5 tables + 7 indexes + seeded categories. Kept in sync with
|
||||
-- `balance_schema.sql` (the source of truth applied by Migration v9 in lib.rs).
|
||||
-- New profiles created from this consolidated schema get the balance feature
|
||||
-- preinstalled without needing to replay v9 separately.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
i18n_key TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('simple','priced')),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_seed INTEGER NOT NULL DEFAULT 0,
|
||||
asset_type TEXT CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
balance_category_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
symbol TEXT,
|
||||
currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'),
|
||||
notes TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
archived_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapshot_date DATE NOT NULL UNIQUE,
|
||||
notes TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_snapshot_lines (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapshot_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
quantity REAL,
|
||||
unit_price REAL,
|
||||
value REAL NOT NULL,
|
||||
price_source TEXT,
|
||||
price_fetched_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT,
|
||||
UNIQUE(snapshot_id, account_id),
|
||||
CHECK (
|
||||
(quantity IS NULL AND unit_price IS NULL)
|
||||
OR (quantity IS NOT NULL AND unit_price IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_account_transfers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
transaction_id INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL CHECK(direction IN ('in','out')),
|
||||
notes TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT,
|
||||
UNIQUE(transaction_id, account_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1;
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date);
|
||||
|
||||
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed, asset_type) VALUES
|
||||
('cash', 'balance.category.cash', 'simple', 10, 1, NULL),
|
||||
('tfsa', 'balance.category.tfsa', 'simple', 20, 1, NULL),
|
||||
('rrsp', 'balance.category.rrsp', 'simple', 30, 1, NULL),
|
||||
('fund', 'balance.category.fund', 'simple', 40, 1, NULL),
|
||||
('other', 'balance.category.other', 'simple', 50, 1, NULL),
|
||||
('stock', 'balance.category.stock', 'priced', 60, 1, 'stock'),
|
||||
('crypto', 'balance.category.crypto', 'priced', 70, 1, 'crypto');
|
||||
|
||||
-- Default preferences (new profiles ship with the v1 IPC taxonomy)
|
||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
|
||||
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 ('date_format', 'DD/MM/YYYY');
|
||||
INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('categories_schema_version', 'v1');
|
||||
|
||||
-- ============================================================================
|
||||
-- Seed v1 — IPC Statistique Canada-aligned, 3 levels, Canada/Québec
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Reset any pre-existing category data (possible when tauri-plugin-sql runs
|
||||
-- the historical v2 seed migration on this fresh DB before this script
|
||||
-- executes). Keywords/categories are wiped and re-inserted with the v1 IDs
|
||||
-- (1000+ range) so existing references in migrations v3-v7 stay inert.
|
||||
-- ============================================================================
|
||||
DELETE FROM keywords;
|
||||
UPDATE transactions SET category_id = NULL;
|
||||
DELETE FROM categories;
|
||||
|
||||
-- LEVEL 1 — Roots (9 IPC components + Revenus + Transferts)
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1000, 'Revenus', NULL, 'income', '#16a34a', 0, 1, 'categoriesSeed.revenus.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1100, 'Alimentation', NULL, 'expense', '#ea580c', 0, 2, 'categoriesSeed.alimentation.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1200, 'Logement', NULL, 'expense', '#dc2626', 0, 3, 'categoriesSeed.logement.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1300, 'Ménage & ameublement', NULL, 'expense', '#ca8a04', 0, 4, 'categoriesSeed.menage.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1400, 'Vêtements & chaussures', NULL, 'expense', '#d946ef', 0, 5, 'categoriesSeed.vetements.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1500, 'Transport', NULL, 'expense', '#2563eb', 0, 6, 'categoriesSeed.transport.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1600, 'Santé & soins personnels', NULL, 'expense', '#f43f5e', 0, 7, 'categoriesSeed.sante.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1700, 'Loisirs, formation & lecture', NULL, 'expense', '#8b5cf6', 0, 8, 'categoriesSeed.loisirs.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1800, 'Boissons, tabac & cannabis', NULL, 'expense', '#7c3aed', 0, 9, 'categoriesSeed.consommation.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1900, 'Finances & obligations', NULL, 'expense', '#6b7280', 0, 10, 'categoriesSeed.finances.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1950, 'Transferts & placements', NULL, 'transfer', '#0ea5e9', 0, 11, 'categoriesSeed.transferts.root');
|
||||
|
||||
-- 1000 — Revenus
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1010, 'Emploi', 1000, 'income', '#22c55e', 0, 1, 'categoriesSeed.revenus.emploi.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1011, 'Paie régulière', 1010, 'income', '#22c55e', 1, 1, 'categoriesSeed.revenus.emploi.paie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1012, 'Primes & bonus', 1010, 'income', '#4ade80', 1, 2, 'categoriesSeed.revenus.emploi.primes');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1013, 'Travail autonome', 1010, 'income', '#86efac', 1, 3, 'categoriesSeed.revenus.emploi.autonome');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1020, 'Gouvernemental', 1000, 'income', '#16a34a', 0, 2, 'categoriesSeed.revenus.gouvernemental.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1021, 'Remboursement impôt', 1020, 'income', '#16a34a', 1, 1, 'categoriesSeed.revenus.gouvernemental.remboursementImpot');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1022, 'Allocations familiales', 1020, 'income', '#15803d', 1, 2, 'categoriesSeed.revenus.gouvernemental.allocations');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1023, 'Crédits TPS/TVQ', 1020, 'income', '#166534', 1, 3, 'categoriesSeed.revenus.gouvernemental.credits');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1024, 'Assurance-emploi / RQAP', 1020, 'income', '#14532d', 1, 4, 'categoriesSeed.revenus.gouvernemental.assuranceEmploi');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1030, 'Revenus de placement', 1000, 'income', '#10b981', 0, 3, 'categoriesSeed.revenus.placement.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1031, 'Intérêts & dividendes', 1030, 'income', '#10b981', 1, 1, 'categoriesSeed.revenus.placement.interets');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1032, 'Gains en capital', 1030, 'income', '#059669', 1, 2, 'categoriesSeed.revenus.placement.capital');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1033, 'Revenus locatifs', 1030, 'income', '#047857', 1, 3, 'categoriesSeed.revenus.placement.locatifs');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1090, 'Autres revenus', 1000, 'income', '#84cc16', 1, 9, 'categoriesSeed.revenus.autres');
|
||||
|
||||
-- 1100 — Alimentation
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1110, 'Épicerie & marché', 1100, 'expense', '#ea580c', 0, 1, 'categoriesSeed.alimentation.epicerie.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1111, 'Épicerie régulière', 1110, 'expense', '#ea580c', 1, 1, 'categoriesSeed.alimentation.epicerie.reguliere');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1112, 'Boucherie & poissonnerie', 1110, 'expense', '#c2410c', 1, 2, 'categoriesSeed.alimentation.epicerie.boucherie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1113, 'Boulangerie & pâtisserie', 1110, 'expense', '#9a3412', 1, 3, 'categoriesSeed.alimentation.epicerie.boulangerie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1114, 'Dépanneur', 1110, 'expense', '#7c2d12', 1, 4, 'categoriesSeed.alimentation.epicerie.depanneur');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1115, 'Marché & produits spécialisés', 1110, 'expense', '#fb923c', 1, 5, 'categoriesSeed.alimentation.epicerie.marche');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1120, 'Restauration', 1100, 'expense', '#f97316', 0, 2, 'categoriesSeed.alimentation.restauration.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1121, 'Restaurant', 1120, 'expense', '#f97316', 1, 1, 'categoriesSeed.alimentation.restauration.restaurant');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1122, 'Café & boulangerie rapide', 1120, 'expense', '#fb923c', 1, 2, 'categoriesSeed.alimentation.restauration.cafe');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1123, 'Restauration rapide', 1120, 'expense', '#fdba74', 1, 3, 'categoriesSeed.alimentation.restauration.fastfood');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1124, 'Livraison à domicile', 1120, 'expense', '#fed7aa', 1, 4, 'categoriesSeed.alimentation.restauration.livraison');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1125, 'Cantine & cafétéria', 1120, 'expense', '#ffedd5', 1, 5, 'categoriesSeed.alimentation.restauration.cantine');
|
||||
|
||||
-- 1200 — Logement
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1210, 'Habitation principale', 1200, 'expense', '#dc2626', 0, 1, 'categoriesSeed.logement.habitation.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1211, 'Loyer', 1210, 'expense', '#dc2626', 1, 1, 'categoriesSeed.logement.habitation.loyer');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1212, 'Hypothèque', 1210, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.logement.habitation.hypotheque');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1213, 'Taxes municipales & scolaires', 1210, 'expense', '#991b1b', 1, 3, 'categoriesSeed.logement.habitation.taxes');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1214, 'Charges de copropriété', 1210, 'expense', '#7f1d1d', 1, 4, 'categoriesSeed.logement.habitation.copropriete');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1220, 'Services publics', 1200, 'expense', '#ef4444', 0, 2, 'categoriesSeed.logement.services.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1221, 'Électricité', 1220, 'expense', '#ef4444', 1, 1, 'categoriesSeed.logement.services.electricite');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1222, 'Gaz naturel', 1220, 'expense', '#f87171', 1, 2, 'categoriesSeed.logement.services.gaz');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1223, 'Chauffage (mazout, propane)', 1220, 'expense', '#fca5a5', 1, 3, 'categoriesSeed.logement.services.chauffage');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1224, 'Eau & égouts', 1220, 'expense', '#fecaca', 1, 4, 'categoriesSeed.logement.services.eau');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1230, 'Communications', 1200, 'expense', '#6366f1', 0, 3, 'categoriesSeed.logement.communications.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1231, 'Internet résidentiel', 1230, 'expense', '#6366f1', 1, 1, 'categoriesSeed.logement.communications.internet');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1232, 'Téléphonie mobile', 1230, 'expense', '#818cf8', 1, 2, 'categoriesSeed.logement.communications.mobile');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1233, 'Téléphonie résidentielle', 1230, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.logement.communications.residentielle');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1234, 'Câblodistribution & streaming TV', 1230, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.logement.communications.streamingTv');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1240, 'Entretien & réparations', 1200, 'expense', '#e11d48', 0, 4, 'categoriesSeed.logement.entretien.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1241, 'Entretien général', 1240, 'expense', '#e11d48', 1, 1, 'categoriesSeed.logement.entretien.general');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1242, 'Rénovations', 1240, 'expense', '#be123c', 1, 2, 'categoriesSeed.logement.entretien.renovations');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1243, 'Matériaux & outils', 1240, 'expense', '#9f1239', 1, 3, 'categoriesSeed.logement.entretien.materiaux');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1244, 'Aménagement paysager', 1240, 'expense', '#881337', 1, 4, 'categoriesSeed.logement.entretien.paysager');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1250, 'Assurance habitation', 1200, 'expense', '#14b8a6', 1, 5, 'categoriesSeed.logement.assurance');
|
||||
|
||||
-- 1300 — Ménage & ameublement
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1310, 'Ameublement', 1300, 'expense', '#ca8a04', 0, 1, 'categoriesSeed.menage.ameublement.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1311, 'Meubles', 1310, 'expense', '#ca8a04', 1, 1, 'categoriesSeed.menage.ameublement.meubles');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1312, 'Électroménagers', 1310, 'expense', '#a16207', 1, 2, 'categoriesSeed.menage.ameublement.electromenagers');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1313, 'Décoration', 1310, 'expense', '#854d0e', 1, 3, 'categoriesSeed.menage.ameublement.decoration');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1320, 'Fournitures ménagères', 1300, 'expense', '#eab308', 0, 2, 'categoriesSeed.menage.fournitures.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1321, 'Produits d''entretien', 1320, 'expense', '#eab308', 1, 1, 'categoriesSeed.menage.fournitures.entretien');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1322, 'Literie & linge de maison', 1320, 'expense', '#facc15', 1, 2, 'categoriesSeed.menage.fournitures.literie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1323, 'Vaisselle & ustensiles', 1320, 'expense', '#fde047', 1, 3, 'categoriesSeed.menage.fournitures.vaisselle');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1330, 'Services domestiques', 1300, 'expense', '#fbbf24', 0, 3, 'categoriesSeed.menage.services.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1331, 'Ménage & nettoyage', 1330, 'expense', '#fbbf24', 1, 1, 'categoriesSeed.menage.services.nettoyage');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1332, 'Buanderie & pressing', 1330, 'expense', '#fcd34d', 1, 2, 'categoriesSeed.menage.services.buanderie');
|
||||
|
||||
-- 1400 — Vêtements & chaussures
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1410, 'Vêtements adultes', 1400, 'expense', '#d946ef', 1, 1, 'categoriesSeed.vetements.adultes');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1420, 'Vêtements enfants', 1400, 'expense', '#c026d3', 1, 2, 'categoriesSeed.vetements.enfants');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1430, 'Chaussures', 1400, 'expense', '#a21caf', 1, 3, 'categoriesSeed.vetements.chaussures');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1440, 'Accessoires & bijoux', 1400, 'expense', '#86198f', 1, 4, 'categoriesSeed.vetements.accessoires');
|
||||
|
||||
-- 1500 — Transport
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1510, 'Véhicule personnel', 1500, 'expense', '#2563eb', 0, 1, 'categoriesSeed.transport.vehicule.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1511, 'Achat / location véhicule', 1510, 'expense', '#2563eb', 1, 1, 'categoriesSeed.transport.vehicule.achat');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1512, 'Essence', 1510, 'expense', '#1d4ed8', 1, 2, 'categoriesSeed.transport.vehicule.essence');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1513, 'Entretien & réparations auto', 1510, 'expense', '#1e40af', 1, 3, 'categoriesSeed.transport.vehicule.entretien');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1514, 'Immatriculation & permis', 1510, 'expense', '#1e3a8a', 1, 4, 'categoriesSeed.transport.vehicule.immatriculation');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1515, 'Stationnement & péages', 1510, 'expense', '#3b82f6', 1, 5, 'categoriesSeed.transport.vehicule.stationnement');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1516, 'Assurance auto', 1510, 'expense', '#60a5fa', 1, 6, 'categoriesSeed.transport.vehicule.assurance');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1520, 'Transport public', 1500, 'expense', '#0ea5e9', 0, 2, 'categoriesSeed.transport.public.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1521, 'Autobus & métro', 1520, 'expense', '#0ea5e9', 1, 1, 'categoriesSeed.transport.public.autobus');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1522, 'Train de banlieue', 1520, 'expense', '#0284c7', 1, 2, 'categoriesSeed.transport.public.train');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1523, 'Taxi & covoiturage', 1520, 'expense', '#0369a1', 1, 3, 'categoriesSeed.transport.public.taxi');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1530, 'Voyages longue distance', 1500, 'expense', '#38bdf8', 0, 3, 'categoriesSeed.transport.voyages.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1531, 'Avion', 1530, 'expense', '#38bdf8', 1, 1, 'categoriesSeed.transport.voyages.avion');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1532, 'Train & autocar', 1530, 'expense', '#7dd3fc', 1, 2, 'categoriesSeed.transport.voyages.trainAutocar');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1533, 'Hébergement', 1530, 'expense', '#bae6fd', 1, 3, 'categoriesSeed.transport.voyages.hebergement');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1534, 'Location véhicule voyage', 1530, 'expense', '#e0f2fe', 1, 4, 'categoriesSeed.transport.voyages.location');
|
||||
|
||||
-- 1600 — Santé & soins personnels
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1610, 'Soins médicaux', 1600, 'expense', '#f43f5e', 0, 1, 'categoriesSeed.sante.medicaux.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1611, 'Pharmacie', 1610, 'expense', '#f43f5e', 1, 1, 'categoriesSeed.sante.medicaux.pharmacie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1612, 'Consultations médicales', 1610, 'expense', '#e11d48', 1, 2, 'categoriesSeed.sante.medicaux.consultations');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1613, 'Dentiste & orthodontiste', 1610, 'expense', '#be123c', 1, 3, 'categoriesSeed.sante.medicaux.dentiste');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1614, 'Optométrie & lunettes', 1610, 'expense', '#9f1239', 1, 4, 'categoriesSeed.sante.medicaux.optometrie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1615, 'Thérapies (physio, psycho, etc.)', 1610, 'expense', '#881337', 1, 5, 'categoriesSeed.sante.medicaux.therapies');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1616, 'Assurance santé complémentaire', 1610, 'expense', '#fb7185', 1, 6, 'categoriesSeed.sante.medicaux.assurance');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1620, 'Soins personnels', 1600, 'expense', '#fb7185', 0, 2, 'categoriesSeed.sante.personnels.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1621, 'Coiffure & esthétique', 1620, 'expense', '#fb7185', 1, 1, 'categoriesSeed.sante.personnels.coiffure');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1622, 'Produits de soins corporels', 1620, 'expense', '#fda4af', 1, 2, 'categoriesSeed.sante.personnels.soins');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1630, 'Assurance vie & invalidité', 1600, 'expense', '#14b8a6', 1, 3, 'categoriesSeed.sante.assuranceVie');
|
||||
|
||||
-- 1700 — Loisirs, formation & lecture
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1710, 'Divertissement', 1700, 'expense', '#8b5cf6', 0, 1, 'categoriesSeed.loisirs.divertissement.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1711, 'Cinéma & spectacles', 1710, 'expense', '#8b5cf6', 1, 1, 'categoriesSeed.loisirs.divertissement.cinema');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1712, 'Jeux vidéo & consoles', 1710, 'expense', '#a78bfa', 1, 2, 'categoriesSeed.loisirs.divertissement.jeux');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1713, 'Streaming vidéo', 1710, 'expense', '#c4b5fd', 1, 3, 'categoriesSeed.loisirs.divertissement.streamingVideo');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1714, 'Streaming musique & audio', 1710, 'expense', '#ddd6fe', 1, 4, 'categoriesSeed.loisirs.divertissement.streamingMusique');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1715, 'Jouets & passe-temps', 1710, 'expense', '#ede9fe', 1, 5, 'categoriesSeed.loisirs.divertissement.jouets');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1720, 'Sports & plein air', 1700, 'expense', '#22c55e', 0, 2, 'categoriesSeed.loisirs.sports.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1721, 'Abonnements sportifs', 1720, 'expense', '#22c55e', 1, 1, 'categoriesSeed.loisirs.sports.abonnements');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1722, 'Équipement sportif', 1720, 'expense', '#4ade80', 1, 2, 'categoriesSeed.loisirs.sports.equipement');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1723, 'Parcs & activités plein air', 1720, 'expense', '#86efac', 1, 3, 'categoriesSeed.loisirs.sports.parcs');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1730, 'Formation & éducation', 1700, 'expense', '#6366f1', 0, 3, 'categoriesSeed.loisirs.formation.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1731, 'Scolarité (frais)', 1730, 'expense', '#6366f1', 1, 1, 'categoriesSeed.loisirs.formation.scolarite');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1732, 'Matériel scolaire', 1730, 'expense', '#818cf8', 1, 2, 'categoriesSeed.loisirs.formation.materiel');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1733, 'Cours & certifications', 1730, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.loisirs.formation.cours');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1734, 'Abonnements professionnels', 1730, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.loisirs.formation.abonnements');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1740, 'Lecture & médias', 1700, 'expense', '#ec4899', 0, 4, 'categoriesSeed.loisirs.lecture.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1741, 'Livres', 1740, 'expense', '#ec4899', 1, 1, 'categoriesSeed.loisirs.lecture.livres');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1742, 'Journaux & magazines', 1740, 'expense', '#f472b6', 1, 2, 'categoriesSeed.loisirs.lecture.journaux');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1750, 'Animaux de compagnie', 1700, 'expense', '#a855f7', 0, 5, 'categoriesSeed.loisirs.animaux.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1751, 'Nourriture & accessoires animaux', 1750, 'expense', '#a855f7', 1, 1, 'categoriesSeed.loisirs.animaux.nourriture');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1752, 'Vétérinaire', 1750, 'expense', '#c084fc', 1, 2, 'categoriesSeed.loisirs.animaux.veterinaire');
|
||||
|
||||
-- 1800 — Boissons, tabac & cannabis
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1810, 'Alcool (SAQ, microbrasseries)', 1800, 'expense', '#7c3aed', 1, 1, 'categoriesSeed.consommation.alcool');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1820, 'Cannabis (SQDC)', 1800, 'expense', '#6d28d9', 1, 2, 'categoriesSeed.consommation.cannabis');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1830, 'Tabac', 1800, 'expense', '#5b21b6', 1, 3, 'categoriesSeed.consommation.tabac');
|
||||
|
||||
-- 1900 — Finances & obligations
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1910, 'Frais bancaires', 1900, 'expense', '#6b7280', 0, 1, 'categoriesSeed.finances.fraisBancaires.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1911, 'Frais de compte', 1910, 'expense', '#6b7280', 1, 1, 'categoriesSeed.finances.fraisBancaires.compte');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1912, 'Intérêts & frais de crédit', 1910, 'expense', '#9ca3af', 1, 2, 'categoriesSeed.finances.fraisBancaires.interets');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1913, 'Frais de change', 1910, 'expense', '#d1d5db', 1, 3, 'categoriesSeed.finances.fraisBancaires.change');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1920, 'Impôts & taxes', 1900, 'expense', '#dc2626', 0, 2, 'categoriesSeed.finances.impots.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1921, 'Impôt fédéral', 1920, 'expense', '#dc2626', 1, 1, 'categoriesSeed.finances.impots.federal');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1922, 'Impôt provincial', 1920, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.finances.impots.provincial');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1923, 'Acomptes provisionnels', 1920, 'expense', '#991b1b', 1, 3, 'categoriesSeed.finances.impots.acomptes');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1930, 'Dons & cotisations', 1900, 'expense', '#ec4899', 0, 3, 'categoriesSeed.finances.dons.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1931, 'Dons de charité', 1930, 'expense', '#ec4899', 1, 1, 'categoriesSeed.finances.dons.charite');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1932, 'Cotisations professionnelles', 1930, 'expense', '#f472b6', 1, 2, 'categoriesSeed.finances.dons.professionnelles');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1933, 'Cotisations syndicales', 1930, 'expense', '#f9a8d4', 1, 3, 'categoriesSeed.finances.dons.syndicales');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1940, 'Cadeaux', 1900, 'expense', '#f43f5e', 1, 4, 'categoriesSeed.finances.cadeaux');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1945, 'Retrait cash', 1900, 'expense', '#57534e', 1, 5, 'categoriesSeed.finances.cash');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1946, 'Achats divers non catégorisés', 1900, 'expense', '#78716c', 1, 6, 'categoriesSeed.finances.divers');
|
||||
|
||||
-- 1950 — Transferts & placements
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1960, 'Épargne & placements', 1950, 'transfer', '#0ea5e9', 0, 1, 'categoriesSeed.transferts.epargne.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1961, 'REER / RRSP', 1960, 'transfer', '#0ea5e9', 1, 1, 'categoriesSeed.transferts.epargne.reer');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1962, 'CELI / TFSA', 1960, 'transfer', '#0284c7', 1, 2, 'categoriesSeed.transferts.epargne.celi');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1963, 'REEE / RESP', 1960, 'transfer', '#0369a1', 1, 3, 'categoriesSeed.transferts.epargne.reee');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1964, 'Compte non-enregistré', 1960, 'transfer', '#075985', 1, 4, 'categoriesSeed.transferts.epargne.nonEnregistre');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1965, 'Fonds d''urgence', 1960, 'transfer', '#0c4a6e', 1, 5, 'categoriesSeed.transferts.epargne.urgence');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1970, 'Remboursement de dette', 1950, 'transfer', '#7c3aed', 0, 2, 'categoriesSeed.transferts.dette.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1971, 'Paiement carte crédit', 1970, 'transfer', '#7c3aed', 1, 1, 'categoriesSeed.transferts.dette.carteCredit');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1972, 'Remboursement prêt étudiant', 1970, 'transfer', '#8b5cf6', 1, 2, 'categoriesSeed.transferts.dette.etudiant');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1973, 'Remboursement prêt perso', 1970, 'transfer', '#a78bfa', 1, 3, 'categoriesSeed.transferts.dette.personnel');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1980, 'Transferts internes', 1950, 'transfer', '#64748b', 1, 3, 'categoriesSeed.transferts.internes');
|
||||
|
||||
-- ============================================================================
|
||||
-- Keywords — Canadian suppliers (150+ entries)
|
||||
-- ============================================================================
|
||||
|
||||
-- Alimentation > Épicerie régulière (1111)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('METRO', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('IGA', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MAXI', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUPER C', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LOBLAWS', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROVIGO', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ADONIS', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WHOLE FOODS', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AVRIL', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RACHELLE-BERY', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COSTCO', 1111, 50);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WALMART', 1111, 50);
|
||||
|
||||
-- Épicerie > Boucherie (1112)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCHERIE', 1112, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('POISSONNERIE', 1112, 0);
|
||||
|
||||
-- Épicerie > Boulangerie (1113)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOULANGERIE', 1113, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PATISSERIE', 1113, 0);
|
||||
|
||||
-- Épicerie > Dépanneur (1114)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD', 1114, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPANNEUR', 1114, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('7-ELEVEN', 1114, 0);
|
||||
|
||||
-- Restauration > Restaurant (1121)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RESTAURANT', 1121, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRASSERIE', 1121, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BISTRO', 1121, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUSHI', 1121, 0);
|
||||
|
||||
-- Restauration > Café (1122)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STARBUCKS', 1122, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIM HORTONS', 1122, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SECOND CUP', 1122, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VAN HOUTTE', 1122, 0);
|
||||
|
||||
-- Restauration > Fast food (1123)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MCDONALD', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUBWAY', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('A&W', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BURGER KING', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('KFC', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOMINOS', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PIZZA HUT', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELLE PROVINCE', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ST-HUBERT', 1123, 0);
|
||||
|
||||
-- Restauration > Livraison (1124)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOORDASH', 1124, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DD/DOORDASH', 1124, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER EATS', 1124, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SKIPTHEDISHES', 1124, 0);
|
||||
|
||||
-- Logement > Hypothèque (1212)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MTG/HYP', 1212, 0);
|
||||
|
||||
-- Logement > Taxes municipales (1213)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('M-ST-HILAIRE TX', 1213, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXES MUNICIPALES', 1213, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CSS PATRIOT', 1213, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXE SCOLAIRE', 1213, 0);
|
||||
|
||||
-- Électricité (1221)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO-QUEBEC', 1221, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO QUEBEC', 1221, 0);
|
||||
|
||||
-- Gaz (1222)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ENERGIR', 1222, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAZ METRO', 1222, 0);
|
||||
|
||||
-- Internet (1231)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIDEOTRON', 1231, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL INTERNET', 1231, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORICOM', 1231, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COGECO', 1231, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EBOX', 1231, 0);
|
||||
|
||||
-- Mobile (1232)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIZZ', 1232, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('KOODO', 1232, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PUBLIC MOBILE', 1232, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIRGIN', 1232, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL MOBILITE', 1232, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TELUS MOBILE', 1232, 0);
|
||||
|
||||
-- Entretien maison (1241)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('IKEA', 1241, 0);
|
||||
|
||||
-- Matériaux & outils (1243)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANADIAN TIRE', 1243, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANAC', 1243, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RONA', 1243, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HOME DEPOT', 1243, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BMR', 1243, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRINCESS AUTO', 1243, 0);
|
||||
|
||||
-- Assurance habitation (1250)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELAIR', 1250, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRYSM', 1250, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('INTACT ASSURANCE', 1250, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DESJARDINS ASSURANCE', 1250, 0);
|
||||
|
||||
-- Meubles (1311)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TANGUAY', 1311, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEON', 1311, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STRUCTUBE', 1311, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRICK', 1311, 0);
|
||||
|
||||
-- Électroménagers (1312)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BEST BUY', 1312, 0);
|
||||
|
||||
-- Décoration (1313)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCLAIR', 1313, 0);
|
||||
|
||||
-- Vêtements (1410)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIQLO', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WINNERS', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SIMONS', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARKS', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('H&M', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('OLD NAVY', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAP', 1410, 0);
|
||||
|
||||
-- Transport — Essence (1512)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SHELL', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ESSO', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ULTRAMAR', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO-CANADA', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO CANADA', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CREVIER', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HARNOIS', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD ESSENCE', 1512, 10);
|
||||
|
||||
-- Permis / SAAQ (1514)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAAQ', 1514, 0);
|
||||
|
||||
-- Transport public — autobus/métro (1521)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STM', 1521, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RTC', 1521, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STL', 1521, 0);
|
||||
|
||||
-- Train de banlieue (1522)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE MONT-SAINT', 1522, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE SAINT-HUBERT', 1522, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE CENTRALE', 1522, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EXO', 1522, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('REM', 1522, 0);
|
||||
|
||||
-- Taxi / Uber (1523)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER', 1523, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LYFT', 1523, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXI', 1523, 0);
|
||||
|
||||
-- Avion (1531)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR CANADA', 1531, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WESTJET', 1531, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR TRANSAT', 1531, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PORTER', 1531, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AEROPORTS DE MONTREAL', 1531, 0);
|
||||
|
||||
-- Hébergement (1533)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIRBNB', 1533, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HILTON', 1533, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARRIOTT', 1533, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOOKING.COM', 1533, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NORWEGIAN CRUISE', 1533, 0);
|
||||
|
||||
-- Pharmacie (1611)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('JEAN COUTU', 1611, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FAMILIPRIX', 1611, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHARMAPRIX', 1611, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROXIM', 1611, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIPRIX', 1611, 0);
|
||||
|
||||
-- Thérapies (1615)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHYSIOACTIF', 1615, 0);
|
||||
|
||||
-- Cinéma & spectacles (1711)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEPLEX', 1711, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEMA DU PARC', 1711, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TICKETMASTER', 1711, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CLUB SODA', 1711, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEPOINTDEVENTE', 1711, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EVENBRITE', 1711, 0);
|
||||
|
||||
-- Jeux vidéo (1712)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STEAMGAMES', 1712, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PLAYSTATION', 1712, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NINTENDO', 1712, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('XBOX', 1712, 0);
|
||||
|
||||
-- Streaming vidéo (1713)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NETFLIX', 1713, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRIMEVIDEO', 1713, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DISNEY PLUS', 1713, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRAVE', 1713, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE TV', 1713, 0);
|
||||
|
||||
-- Streaming musique (1714)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPOTIFY', 1714, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE MUSIC', 1714, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('YOUTUBE MUSIC', 1714, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIDAL', 1714, 0);
|
||||
|
||||
-- Jouets & hobbies (1715)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEGO', 1715, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TOYS R US', 1715, 0);
|
||||
|
||||
-- Équipement sportif (1722)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MOUNTAIN EQUIPMENT', 1722, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LA CORDEE', 1722, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DECATHLON', 1722, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPORTS EXPERTS', 1722, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ATMOSPHERE', 1722, 0);
|
||||
|
||||
-- Parcs & activités (1723)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SEPAQ', 1723, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BLOC SHOP', 1723, 0);
|
||||
|
||||
-- Lecture (1741)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RENAUD-BRAY', 1741, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('INDIGO', 1741, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARCHAMBAULT', 1741, 0);
|
||||
|
||||
-- Animaux — nourriture (1751)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MONDOU', 1751, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PET SMART', 1751, 0);
|
||||
|
||||
-- Alcool (1810)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAQ', 1810, 0);
|
||||
|
||||
-- Cannabis (1820)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SQDC', 1820, 0);
|
||||
|
||||
-- Frais bancaires (1911)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROGRAMME PERFORMANCE', 1911, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FRAIS MENSUELS', 1911, 0);
|
||||
|
||||
-- Impôts (1921, 1922)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GOUV. QUEBEC', 1922, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('REVENU QUEBEC', 1922, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARC IMPOT', 1921, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRA TAX', 1921, 0);
|
||||
|
||||
-- Dons de charité (1931)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('OXFAM', 1931, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CENTRAIDE', 1931, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FPA', 1931, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CROIX-ROUGE', 1931, 0);
|
||||
|
||||
-- Cotisations professionnelles (1932)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORDRE DES COMPTABL', 1932, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CPA CANADA', 1932, 0);
|
||||
|
||||
-- Cadeaux (1940)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DANS UN JARDIN', 1940, 0);
|
||||
|
||||
-- Divers (1946) — catch-all marketplace
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMAZON', 1946, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMZN', 1946, 0);
|
||||
|
||||
-- Placements (1964)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WS INVESTMENTS', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WEALTHSIMPLE', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PEAK INVESTMENT', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DYNAMIC FUND', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIDELITY', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AGF', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('QUESTRADE', 1964, 0);
|
||||
|
||||
-- Paie (1011)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAY/PAY', 1011, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPOT PAIE', 1011, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAYROLL', 1011, 0);
|
||||
764
src-tauri/src/database/fixtures/consolidated_v0.9.1.sql
Normal file
764
src-tauri/src/database/fixtures/consolidated_v0.9.1.sql
Normal file
|
|
@ -0,0 +1,764 @@
|
|||
-- Consolidated schema for new profile databases
|
||||
-- This file bakes in the base schema + all migrations (v3-v8) and pre-seeds
|
||||
-- the v1 IPC-aligned category taxonomy so that brand-new profiles immediately
|
||||
-- use the new standard. Existing profiles keep their v2 seed and are only
|
||||
-- tagged via the migration (categories_schema_version = 'v2').
|
||||
-- Used ONLY for initializing new profile databases (not for the default profile).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_sources (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
date_format TEXT NOT NULL DEFAULT '%d/%m/%Y',
|
||||
delimiter TEXT NOT NULL DEFAULT ';',
|
||||
encoding TEXT NOT NULL DEFAULT 'utf-8',
|
||||
column_mapping TEXT NOT NULL,
|
||||
skip_lines INTEGER NOT NULL DEFAULT 0,
|
||||
has_header INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS imported_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_id INTEGER NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
file_hash TEXT NOT NULL,
|
||||
import_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
row_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'completed',
|
||||
notes TEXT,
|
||||
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE CASCADE,
|
||||
UNIQUE(source_id, filename)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
parent_id INTEGER,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'expense',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_inputable INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
i18n_key TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS suppliers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
normalized_name TEXT NOT NULL,
|
||||
category_id INTEGER,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS keywords (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
keyword TEXT NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
supplier_id INTEGER,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
|
||||
UNIQUE(keyword, category_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date DATE NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
category_id INTEGER,
|
||||
supplier_id INTEGER,
|
||||
source_id INTEGER,
|
||||
file_id INTEGER,
|
||||
original_description TEXT,
|
||||
notes TEXT,
|
||||
is_manually_categorized INTEGER NOT NULL DEFAULT 0,
|
||||
is_split INTEGER NOT NULL DEFAULT 0,
|
||||
parent_transaction_id INTEGER,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (supplier_id) REFERENCES suppliers(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (source_id) REFERENCES import_sources(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (file_id) REFERENCES imported_files(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (parent_transaction_id) REFERENCES transactions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS adjustments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
date DATE NOT NULL,
|
||||
is_recurring INTEGER NOT NULL DEFAULT 0,
|
||||
recurrence_rule TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS adjustment_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
adjustment_id INTEGER NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
description TEXT,
|
||||
FOREIGN KEY (adjustment_id) REFERENCES adjustments(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category_id INTEGER NOT NULL,
|
||||
year INTEGER NOT NULL,
|
||||
month INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
notes TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
|
||||
UNIQUE(category_id, year, month)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS budget_template_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
template_id INTEGER NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
FOREIGN KEY (template_id) REFERENCES budget_templates(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
|
||||
UNIQUE(template_id, category_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_config_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
delimiter TEXT NOT NULL DEFAULT ';',
|
||||
encoding TEXT NOT NULL DEFAULT 'utf-8',
|
||||
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
|
||||
skip_lines INTEGER NOT NULL DEFAULT 0,
|
||||
has_header INTEGER NOT NULL DEFAULT 1,
|
||||
column_mapping TEXT NOT NULL,
|
||||
amount_mode TEXT NOT NULL DEFAULT 'single',
|
||||
sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_supplier ON transactions(supplier_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_source ON transactions(source_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_file ON transactions(file_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_parent ON transactions(parent_transaction_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_parent ON categories(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_suppliers_category ON suppliers(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_suppliers_normalized ON suppliers(normalized_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_keywords_category ON keywords(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_keywords_keyword ON keywords(keyword);
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_entries_period ON budget_entries(year, month);
|
||||
CREATE INDEX IF NOT EXISTS idx_adjustment_entries_adjustment ON adjustment_entries(adjustment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_imported_files_source ON imported_files(source_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- Balance sheet (Bilan) — Migration v9 mirror for new profiles
|
||||
-- ============================================================================
|
||||
-- 5 tables + 7 indexes + seeded categories. Kept in sync with
|
||||
-- `balance_schema.sql` (the source of truth applied by Migration v9 in lib.rs).
|
||||
-- New profiles created from this consolidated schema get the balance feature
|
||||
-- preinstalled without needing to replay v9 separately.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
i18n_key TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK(kind IN ('simple','priced')),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_seed INTEGER NOT NULL DEFAULT 0,
|
||||
asset_type TEXT CHECK(asset_type IS NULL OR asset_type IN ('stock','crypto'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
balance_category_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
symbol TEXT,
|
||||
currency TEXT NOT NULL DEFAULT 'CAD' CHECK(currency = 'CAD'),
|
||||
notes TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
archived_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (balance_category_id) REFERENCES balance_categories(id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapshot_date DATE NOT NULL UNIQUE,
|
||||
notes TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_snapshot_lines (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapshot_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
quantity REAL,
|
||||
unit_price REAL,
|
||||
value REAL NOT NULL,
|
||||
price_source TEXT,
|
||||
price_fetched_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (snapshot_id) REFERENCES balance_snapshots(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE RESTRICT,
|
||||
UNIQUE(snapshot_id, account_id),
|
||||
CHECK (
|
||||
(quantity IS NULL AND unit_price IS NULL)
|
||||
OR (quantity IS NOT NULL AND unit_price IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS balance_account_transfers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL,
|
||||
transaction_id INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL CHECK(direction IN ('in','out')),
|
||||
notes TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (account_id) REFERENCES balance_accounts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (transaction_id) REFERENCES transactions(id) ON DELETE RESTRICT,
|
||||
UNIQUE(transaction_id, account_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_accounts_category ON balance_accounts(balance_category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_accounts_active ON balance_accounts(is_active) WHERE is_active = 1;
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_snapshot ON balance_snapshot_lines(snapshot_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshot_lines_account ON balance_snapshot_lines(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_account ON balance_account_transfers(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_account_transfers_transaction ON balance_account_transfers(transaction_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_balance_snapshots_date ON balance_snapshots(snapshot_date);
|
||||
|
||||
INSERT OR IGNORE INTO balance_categories (key, i18n_key, kind, sort_order, is_seed, asset_type) VALUES
|
||||
('cash', 'balance.category.cash', 'simple', 10, 1, NULL),
|
||||
('tfsa', 'balance.category.tfsa', 'simple', 20, 1, NULL),
|
||||
('rrsp', 'balance.category.rrsp', 'simple', 30, 1, NULL),
|
||||
('fund', 'balance.category.fund', 'simple', 40, 1, NULL),
|
||||
('other', 'balance.category.other', 'simple', 50, 1, NULL),
|
||||
('stock', 'balance.category.stock', 'priced', 60, 1, 'stock'),
|
||||
('crypto', 'balance.category.crypto', 'priced', 70, 1, 'crypto');
|
||||
|
||||
-- Starter accounts (Issue #179): 4 plain accounts seeded for new profiles so
|
||||
-- /balance lands non-empty. They are NOT marked as seed (no is_seed column on
|
||||
-- balance_accounts) — once created they are indistinguishable from
|
||||
-- user-created accounts and can be renamed/archived freely. Existing profiles
|
||||
-- get the same 4 proposed via StarterAccountsModal on first /balance visit.
|
||||
INSERT INTO balance_accounts (balance_category_id, name, currency, is_active) VALUES
|
||||
((SELECT id FROM balance_categories WHERE key = 'cash'), 'Compte chèque', 'CAD', 1),
|
||||
((SELECT id FROM balance_categories WHERE key = 'tfsa'), 'CELI', 'CAD', 1),
|
||||
((SELECT id FROM balance_categories WHERE key = 'rrsp'), 'REER', 'CAD', 1),
|
||||
((SELECT id FROM balance_categories WHERE key = 'other'), 'Compte non-enregistré', 'CAD', 1);
|
||||
|
||||
-- Default preferences (new profiles ship with the v1 IPC taxonomy)
|
||||
INSERT OR IGNORE INTO user_preferences (key, value) VALUES ('language', 'fr');
|
||||
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 ('date_format', 'DD/MM/YYYY');
|
||||
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
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Reset any pre-existing category data (possible when tauri-plugin-sql runs
|
||||
-- the historical v2 seed migration on this fresh DB before this script
|
||||
-- executes). Keywords/categories are wiped and re-inserted with the v1 IDs
|
||||
-- (1000+ range) so existing references in migrations v3-v7 stay inert.
|
||||
-- ============================================================================
|
||||
DELETE FROM keywords;
|
||||
UPDATE transactions SET category_id = NULL;
|
||||
DELETE FROM categories;
|
||||
|
||||
-- LEVEL 1 — Roots (9 IPC components + Revenus + Transferts)
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1000, 'Revenus', NULL, 'income', '#16a34a', 0, 1, 'categoriesSeed.revenus.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1100, 'Alimentation', NULL, 'expense', '#ea580c', 0, 2, 'categoriesSeed.alimentation.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1200, 'Logement', NULL, 'expense', '#dc2626', 0, 3, 'categoriesSeed.logement.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1300, 'Ménage & ameublement', NULL, 'expense', '#ca8a04', 0, 4, 'categoriesSeed.menage.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1400, 'Vêtements & chaussures', NULL, 'expense', '#d946ef', 0, 5, 'categoriesSeed.vetements.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1500, 'Transport', NULL, 'expense', '#2563eb', 0, 6, 'categoriesSeed.transport.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1600, 'Santé & soins personnels', NULL, 'expense', '#f43f5e', 0, 7, 'categoriesSeed.sante.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1700, 'Loisirs, formation & lecture', NULL, 'expense', '#8b5cf6', 0, 8, 'categoriesSeed.loisirs.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1800, 'Boissons, tabac & cannabis', NULL, 'expense', '#7c3aed', 0, 9, 'categoriesSeed.consommation.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1900, 'Finances & obligations', NULL, 'expense', '#6b7280', 0, 10, 'categoriesSeed.finances.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1950, 'Transferts & placements', NULL, 'transfer', '#0ea5e9', 0, 11, 'categoriesSeed.transferts.root');
|
||||
|
||||
-- 1000 — Revenus
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1010, 'Emploi', 1000, 'income', '#22c55e', 0, 1, 'categoriesSeed.revenus.emploi.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1011, 'Paie régulière', 1010, 'income', '#22c55e', 1, 1, 'categoriesSeed.revenus.emploi.paie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1012, 'Primes & bonus', 1010, 'income', '#4ade80', 1, 2, 'categoriesSeed.revenus.emploi.primes');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1013, 'Travail autonome', 1010, 'income', '#86efac', 1, 3, 'categoriesSeed.revenus.emploi.autonome');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1020, 'Gouvernemental', 1000, 'income', '#16a34a', 0, 2, 'categoriesSeed.revenus.gouvernemental.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1021, 'Remboursement impôt', 1020, 'income', '#16a34a', 1, 1, 'categoriesSeed.revenus.gouvernemental.remboursementImpot');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1022, 'Allocations familiales', 1020, 'income', '#15803d', 1, 2, 'categoriesSeed.revenus.gouvernemental.allocations');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1023, 'Crédits TPS/TVQ', 1020, 'income', '#166534', 1, 3, 'categoriesSeed.revenus.gouvernemental.credits');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1024, 'Assurance-emploi / RQAP', 1020, 'income', '#14532d', 1, 4, 'categoriesSeed.revenus.gouvernemental.assuranceEmploi');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1030, 'Revenus de placement', 1000, 'income', '#10b981', 0, 3, 'categoriesSeed.revenus.placement.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1031, 'Intérêts & dividendes', 1030, 'income', '#10b981', 1, 1, 'categoriesSeed.revenus.placement.interets');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1032, 'Gains en capital', 1030, 'income', '#059669', 1, 2, 'categoriesSeed.revenus.placement.capital');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1033, 'Revenus locatifs', 1030, 'income', '#047857', 1, 3, 'categoriesSeed.revenus.placement.locatifs');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1090, 'Autres revenus', 1000, 'income', '#84cc16', 1, 9, 'categoriesSeed.revenus.autres');
|
||||
|
||||
-- 1100 — Alimentation
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1110, 'Épicerie & marché', 1100, 'expense', '#ea580c', 0, 1, 'categoriesSeed.alimentation.epicerie.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1111, 'Épicerie régulière', 1110, 'expense', '#ea580c', 1, 1, 'categoriesSeed.alimentation.epicerie.reguliere');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1112, 'Boucherie & poissonnerie', 1110, 'expense', '#c2410c', 1, 2, 'categoriesSeed.alimentation.epicerie.boucherie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1113, 'Boulangerie & pâtisserie', 1110, 'expense', '#9a3412', 1, 3, 'categoriesSeed.alimentation.epicerie.boulangerie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1114, 'Dépanneur', 1110, 'expense', '#7c2d12', 1, 4, 'categoriesSeed.alimentation.epicerie.depanneur');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1115, 'Marché & produits spécialisés', 1110, 'expense', '#fb923c', 1, 5, 'categoriesSeed.alimentation.epicerie.marche');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1120, 'Restauration', 1100, 'expense', '#f97316', 0, 2, 'categoriesSeed.alimentation.restauration.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1121, 'Restaurant', 1120, 'expense', '#f97316', 1, 1, 'categoriesSeed.alimentation.restauration.restaurant');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1122, 'Café & boulangerie rapide', 1120, 'expense', '#fb923c', 1, 2, 'categoriesSeed.alimentation.restauration.cafe');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1123, 'Restauration rapide', 1120, 'expense', '#fdba74', 1, 3, 'categoriesSeed.alimentation.restauration.fastfood');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1124, 'Livraison à domicile', 1120, 'expense', '#fed7aa', 1, 4, 'categoriesSeed.alimentation.restauration.livraison');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1125, 'Cantine & cafétéria', 1120, 'expense', '#ffedd5', 1, 5, 'categoriesSeed.alimentation.restauration.cantine');
|
||||
|
||||
-- 1200 — Logement
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1210, 'Habitation principale', 1200, 'expense', '#dc2626', 0, 1, 'categoriesSeed.logement.habitation.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1211, 'Loyer', 1210, 'expense', '#dc2626', 1, 1, 'categoriesSeed.logement.habitation.loyer');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1212, 'Hypothèque', 1210, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.logement.habitation.hypotheque');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1213, 'Taxes municipales & scolaires', 1210, 'expense', '#991b1b', 1, 3, 'categoriesSeed.logement.habitation.taxes');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1214, 'Charges de copropriété', 1210, 'expense', '#7f1d1d', 1, 4, 'categoriesSeed.logement.habitation.copropriete');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1220, 'Services publics', 1200, 'expense', '#ef4444', 0, 2, 'categoriesSeed.logement.services.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1221, 'Électricité', 1220, 'expense', '#ef4444', 1, 1, 'categoriesSeed.logement.services.electricite');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1222, 'Gaz naturel', 1220, 'expense', '#f87171', 1, 2, 'categoriesSeed.logement.services.gaz');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1223, 'Chauffage (mazout, propane)', 1220, 'expense', '#fca5a5', 1, 3, 'categoriesSeed.logement.services.chauffage');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1224, 'Eau & égouts', 1220, 'expense', '#fecaca', 1, 4, 'categoriesSeed.logement.services.eau');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1230, 'Communications', 1200, 'expense', '#6366f1', 0, 3, 'categoriesSeed.logement.communications.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1231, 'Internet résidentiel', 1230, 'expense', '#6366f1', 1, 1, 'categoriesSeed.logement.communications.internet');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1232, 'Téléphonie mobile', 1230, 'expense', '#818cf8', 1, 2, 'categoriesSeed.logement.communications.mobile');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1233, 'Téléphonie résidentielle', 1230, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.logement.communications.residentielle');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1234, 'Câblodistribution & streaming TV', 1230, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.logement.communications.streamingTv');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1240, 'Entretien & réparations', 1200, 'expense', '#e11d48', 0, 4, 'categoriesSeed.logement.entretien.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1241, 'Entretien général', 1240, 'expense', '#e11d48', 1, 1, 'categoriesSeed.logement.entretien.general');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1242, 'Rénovations', 1240, 'expense', '#be123c', 1, 2, 'categoriesSeed.logement.entretien.renovations');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1243, 'Matériaux & outils', 1240, 'expense', '#9f1239', 1, 3, 'categoriesSeed.logement.entretien.materiaux');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1244, 'Aménagement paysager', 1240, 'expense', '#881337', 1, 4, 'categoriesSeed.logement.entretien.paysager');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1250, 'Assurance habitation', 1200, 'expense', '#14b8a6', 1, 5, 'categoriesSeed.logement.assurance');
|
||||
|
||||
-- 1300 — Ménage & ameublement
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1310, 'Ameublement', 1300, 'expense', '#ca8a04', 0, 1, 'categoriesSeed.menage.ameublement.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1311, 'Meubles', 1310, 'expense', '#ca8a04', 1, 1, 'categoriesSeed.menage.ameublement.meubles');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1312, 'Électroménagers', 1310, 'expense', '#a16207', 1, 2, 'categoriesSeed.menage.ameublement.electromenagers');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1313, 'Décoration', 1310, 'expense', '#854d0e', 1, 3, 'categoriesSeed.menage.ameublement.decoration');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1320, 'Fournitures ménagères', 1300, 'expense', '#eab308', 0, 2, 'categoriesSeed.menage.fournitures.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1321, 'Produits d''entretien', 1320, 'expense', '#eab308', 1, 1, 'categoriesSeed.menage.fournitures.entretien');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1322, 'Literie & linge de maison', 1320, 'expense', '#facc15', 1, 2, 'categoriesSeed.menage.fournitures.literie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1323, 'Vaisselle & ustensiles', 1320, 'expense', '#fde047', 1, 3, 'categoriesSeed.menage.fournitures.vaisselle');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1330, 'Services domestiques', 1300, 'expense', '#fbbf24', 0, 3, 'categoriesSeed.menage.services.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1331, 'Ménage & nettoyage', 1330, 'expense', '#fbbf24', 1, 1, 'categoriesSeed.menage.services.nettoyage');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1332, 'Buanderie & pressing', 1330, 'expense', '#fcd34d', 1, 2, 'categoriesSeed.menage.services.buanderie');
|
||||
|
||||
-- 1400 — Vêtements & chaussures
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1410, 'Vêtements adultes', 1400, 'expense', '#d946ef', 1, 1, 'categoriesSeed.vetements.adultes');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1420, 'Vêtements enfants', 1400, 'expense', '#c026d3', 1, 2, 'categoriesSeed.vetements.enfants');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1430, 'Chaussures', 1400, 'expense', '#a21caf', 1, 3, 'categoriesSeed.vetements.chaussures');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1440, 'Accessoires & bijoux', 1400, 'expense', '#86198f', 1, 4, 'categoriesSeed.vetements.accessoires');
|
||||
|
||||
-- 1500 — Transport
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1510, 'Véhicule personnel', 1500, 'expense', '#2563eb', 0, 1, 'categoriesSeed.transport.vehicule.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1511, 'Achat / location véhicule', 1510, 'expense', '#2563eb', 1, 1, 'categoriesSeed.transport.vehicule.achat');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1512, 'Essence', 1510, 'expense', '#1d4ed8', 1, 2, 'categoriesSeed.transport.vehicule.essence');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1513, 'Entretien & réparations auto', 1510, 'expense', '#1e40af', 1, 3, 'categoriesSeed.transport.vehicule.entretien');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1514, 'Immatriculation & permis', 1510, 'expense', '#1e3a8a', 1, 4, 'categoriesSeed.transport.vehicule.immatriculation');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1515, 'Stationnement & péages', 1510, 'expense', '#3b82f6', 1, 5, 'categoriesSeed.transport.vehicule.stationnement');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1516, 'Assurance auto', 1510, 'expense', '#60a5fa', 1, 6, 'categoriesSeed.transport.vehicule.assurance');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1520, 'Transport public', 1500, 'expense', '#0ea5e9', 0, 2, 'categoriesSeed.transport.public.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1521, 'Autobus & métro', 1520, 'expense', '#0ea5e9', 1, 1, 'categoriesSeed.transport.public.autobus');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1522, 'Train de banlieue', 1520, 'expense', '#0284c7', 1, 2, 'categoriesSeed.transport.public.train');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1523, 'Taxi & covoiturage', 1520, 'expense', '#0369a1', 1, 3, 'categoriesSeed.transport.public.taxi');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1530, 'Voyages longue distance', 1500, 'expense', '#38bdf8', 0, 3, 'categoriesSeed.transport.voyages.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1531, 'Avion', 1530, 'expense', '#38bdf8', 1, 1, 'categoriesSeed.transport.voyages.avion');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1532, 'Train & autocar', 1530, 'expense', '#7dd3fc', 1, 2, 'categoriesSeed.transport.voyages.trainAutocar');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1533, 'Hébergement', 1530, 'expense', '#bae6fd', 1, 3, 'categoriesSeed.transport.voyages.hebergement');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1534, 'Location véhicule voyage', 1530, 'expense', '#e0f2fe', 1, 4, 'categoriesSeed.transport.voyages.location');
|
||||
|
||||
-- 1600 — Santé & soins personnels
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1610, 'Soins médicaux', 1600, 'expense', '#f43f5e', 0, 1, 'categoriesSeed.sante.medicaux.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1611, 'Pharmacie', 1610, 'expense', '#f43f5e', 1, 1, 'categoriesSeed.sante.medicaux.pharmacie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1612, 'Consultations médicales', 1610, 'expense', '#e11d48', 1, 2, 'categoriesSeed.sante.medicaux.consultations');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1613, 'Dentiste & orthodontiste', 1610, 'expense', '#be123c', 1, 3, 'categoriesSeed.sante.medicaux.dentiste');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1614, 'Optométrie & lunettes', 1610, 'expense', '#9f1239', 1, 4, 'categoriesSeed.sante.medicaux.optometrie');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1615, 'Thérapies (physio, psycho, etc.)', 1610, 'expense', '#881337', 1, 5, 'categoriesSeed.sante.medicaux.therapies');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1616, 'Assurance santé complémentaire', 1610, 'expense', '#fb7185', 1, 6, 'categoriesSeed.sante.medicaux.assurance');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1620, 'Soins personnels', 1600, 'expense', '#fb7185', 0, 2, 'categoriesSeed.sante.personnels.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1621, 'Coiffure & esthétique', 1620, 'expense', '#fb7185', 1, 1, 'categoriesSeed.sante.personnels.coiffure');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1622, 'Produits de soins corporels', 1620, 'expense', '#fda4af', 1, 2, 'categoriesSeed.sante.personnels.soins');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1630, 'Assurance vie & invalidité', 1600, 'expense', '#14b8a6', 1, 3, 'categoriesSeed.sante.assuranceVie');
|
||||
|
||||
-- 1700 — Loisirs, formation & lecture
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1710, 'Divertissement', 1700, 'expense', '#8b5cf6', 0, 1, 'categoriesSeed.loisirs.divertissement.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1711, 'Cinéma & spectacles', 1710, 'expense', '#8b5cf6', 1, 1, 'categoriesSeed.loisirs.divertissement.cinema');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1712, 'Jeux vidéo & consoles', 1710, 'expense', '#a78bfa', 1, 2, 'categoriesSeed.loisirs.divertissement.jeux');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1713, 'Streaming vidéo', 1710, 'expense', '#c4b5fd', 1, 3, 'categoriesSeed.loisirs.divertissement.streamingVideo');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1714, 'Streaming musique & audio', 1710, 'expense', '#ddd6fe', 1, 4, 'categoriesSeed.loisirs.divertissement.streamingMusique');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1715, 'Jouets & passe-temps', 1710, 'expense', '#ede9fe', 1, 5, 'categoriesSeed.loisirs.divertissement.jouets');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1720, 'Sports & plein air', 1700, 'expense', '#22c55e', 0, 2, 'categoriesSeed.loisirs.sports.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1721, 'Abonnements sportifs', 1720, 'expense', '#22c55e', 1, 1, 'categoriesSeed.loisirs.sports.abonnements');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1722, 'Équipement sportif', 1720, 'expense', '#4ade80', 1, 2, 'categoriesSeed.loisirs.sports.equipement');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1723, 'Parcs & activités plein air', 1720, 'expense', '#86efac', 1, 3, 'categoriesSeed.loisirs.sports.parcs');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1730, 'Formation & éducation', 1700, 'expense', '#6366f1', 0, 3, 'categoriesSeed.loisirs.formation.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1731, 'Scolarité (frais)', 1730, 'expense', '#6366f1', 1, 1, 'categoriesSeed.loisirs.formation.scolarite');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1732, 'Matériel scolaire', 1730, 'expense', '#818cf8', 1, 2, 'categoriesSeed.loisirs.formation.materiel');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1733, 'Cours & certifications', 1730, 'expense', '#a5b4fc', 1, 3, 'categoriesSeed.loisirs.formation.cours');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1734, 'Abonnements professionnels', 1730, 'expense', '#c7d2fe', 1, 4, 'categoriesSeed.loisirs.formation.abonnements');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1740, 'Lecture & médias', 1700, 'expense', '#ec4899', 0, 4, 'categoriesSeed.loisirs.lecture.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1741, 'Livres', 1740, 'expense', '#ec4899', 1, 1, 'categoriesSeed.loisirs.lecture.livres');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1742, 'Journaux & magazines', 1740, 'expense', '#f472b6', 1, 2, 'categoriesSeed.loisirs.lecture.journaux');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1750, 'Animaux de compagnie', 1700, 'expense', '#a855f7', 0, 5, 'categoriesSeed.loisirs.animaux.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1751, 'Nourriture & accessoires animaux', 1750, 'expense', '#a855f7', 1, 1, 'categoriesSeed.loisirs.animaux.nourriture');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1752, 'Vétérinaire', 1750, 'expense', '#c084fc', 1, 2, 'categoriesSeed.loisirs.animaux.veterinaire');
|
||||
|
||||
-- 1800 — Boissons, tabac & cannabis
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1810, 'Alcool (SAQ, microbrasseries)', 1800, 'expense', '#7c3aed', 1, 1, 'categoriesSeed.consommation.alcool');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1820, 'Cannabis (SQDC)', 1800, 'expense', '#6d28d9', 1, 2, 'categoriesSeed.consommation.cannabis');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1830, 'Tabac', 1800, 'expense', '#5b21b6', 1, 3, 'categoriesSeed.consommation.tabac');
|
||||
|
||||
-- 1900 — Finances & obligations
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1910, 'Frais bancaires', 1900, 'expense', '#6b7280', 0, 1, 'categoriesSeed.finances.fraisBancaires.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1911, 'Frais de compte', 1910, 'expense', '#6b7280', 1, 1, 'categoriesSeed.finances.fraisBancaires.compte');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1912, 'Intérêts & frais de crédit', 1910, 'expense', '#9ca3af', 1, 2, 'categoriesSeed.finances.fraisBancaires.interets');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1913, 'Frais de change', 1910, 'expense', '#d1d5db', 1, 3, 'categoriesSeed.finances.fraisBancaires.change');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1920, 'Impôts & taxes', 1900, 'expense', '#dc2626', 0, 2, 'categoriesSeed.finances.impots.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1921, 'Impôt fédéral', 1920, 'expense', '#dc2626', 1, 1, 'categoriesSeed.finances.impots.federal');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1922, 'Impôt provincial', 1920, 'expense', '#b91c1c', 1, 2, 'categoriesSeed.finances.impots.provincial');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1923, 'Acomptes provisionnels', 1920, 'expense', '#991b1b', 1, 3, 'categoriesSeed.finances.impots.acomptes');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1930, 'Dons & cotisations', 1900, 'expense', '#ec4899', 0, 3, 'categoriesSeed.finances.dons.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1931, 'Dons de charité', 1930, 'expense', '#ec4899', 1, 1, 'categoriesSeed.finances.dons.charite');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1932, 'Cotisations professionnelles', 1930, 'expense', '#f472b6', 1, 2, 'categoriesSeed.finances.dons.professionnelles');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1933, 'Cotisations syndicales', 1930, 'expense', '#f9a8d4', 1, 3, 'categoriesSeed.finances.dons.syndicales');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1940, 'Cadeaux', 1900, 'expense', '#f43f5e', 1, 4, 'categoriesSeed.finances.cadeaux');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1945, 'Retrait cash', 1900, 'expense', '#57534e', 1, 5, 'categoriesSeed.finances.cash');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1946, 'Achats divers non catégorisés', 1900, 'expense', '#78716c', 1, 6, 'categoriesSeed.finances.divers');
|
||||
|
||||
-- 1950 — Transferts & placements
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1960, 'Épargne & placements', 1950, 'transfer', '#0ea5e9', 0, 1, 'categoriesSeed.transferts.epargne.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1961, 'REER / RRSP', 1960, 'transfer', '#0ea5e9', 1, 1, 'categoriesSeed.transferts.epargne.reer');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1962, 'CELI / TFSA', 1960, 'transfer', '#0284c7', 1, 2, 'categoriesSeed.transferts.epargne.celi');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1963, 'REEE / RESP', 1960, 'transfer', '#0369a1', 1, 3, 'categoriesSeed.transferts.epargne.reee');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1964, 'Compte non-enregistré', 1960, 'transfer', '#075985', 1, 4, 'categoriesSeed.transferts.epargne.nonEnregistre');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1965, 'Fonds d''urgence', 1960, 'transfer', '#0c4a6e', 1, 5, 'categoriesSeed.transferts.epargne.urgence');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1970, 'Remboursement de dette', 1950, 'transfer', '#7c3aed', 0, 2, 'categoriesSeed.transferts.dette.root');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1971, 'Paiement carte crédit', 1970, 'transfer', '#7c3aed', 1, 1, 'categoriesSeed.transferts.dette.carteCredit');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1972, 'Remboursement prêt étudiant', 1970, 'transfer', '#8b5cf6', 1, 2, 'categoriesSeed.transferts.dette.etudiant');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1973, 'Remboursement prêt perso', 1970, 'transfer', '#a78bfa', 1, 3, 'categoriesSeed.transferts.dette.personnel');
|
||||
INSERT INTO categories (id, name, parent_id, type, color, is_inputable, sort_order, i18n_key) VALUES (1980, 'Transferts internes', 1950, 'transfer', '#64748b', 1, 3, 'categoriesSeed.transferts.internes');
|
||||
|
||||
-- ============================================================================
|
||||
-- Keywords — Canadian suppliers (150+ entries)
|
||||
-- ============================================================================
|
||||
|
||||
-- Alimentation > Épicerie régulière (1111)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('METRO', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('IGA', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MAXI', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUPER C', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LOBLAWS', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROVIGO', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ADONIS', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WHOLE FOODS', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AVRIL', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RACHELLE-BERY', 1111, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COSTCO', 1111, 50);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WALMART', 1111, 50);
|
||||
|
||||
-- Épicerie > Boucherie (1112)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCHERIE', 1112, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('POISSONNERIE', 1112, 0);
|
||||
|
||||
-- Épicerie > Boulangerie (1113)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOULANGERIE', 1113, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PATISSERIE', 1113, 0);
|
||||
|
||||
-- Épicerie > Dépanneur (1114)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD', 1114, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPANNEUR', 1114, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('7-ELEVEN', 1114, 0);
|
||||
|
||||
-- Restauration > Restaurant (1121)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RESTAURANT', 1121, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRASSERIE', 1121, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BISTRO', 1121, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUSHI', 1121, 0);
|
||||
|
||||
-- Restauration > Café (1122)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STARBUCKS', 1122, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIM HORTONS', 1122, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SECOND CUP', 1122, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VAN HOUTTE', 1122, 0);
|
||||
|
||||
-- Restauration > Fast food (1123)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MCDONALD', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SUBWAY', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('A&W', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BURGER KING', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('KFC', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOMINOS', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PIZZA HUT', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELLE PROVINCE', 1123, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ST-HUBERT', 1123, 0);
|
||||
|
||||
-- Restauration > Livraison (1124)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DOORDASH', 1124, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DD/DOORDASH', 1124, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER EATS', 1124, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SKIPTHEDISHES', 1124, 0);
|
||||
|
||||
-- Logement > Hypothèque (1212)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MTG/HYP', 1212, 0);
|
||||
|
||||
-- Logement > Taxes municipales (1213)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('M-ST-HILAIRE TX', 1213, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXES MUNICIPALES', 1213, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CSS PATRIOT', 1213, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXE SCOLAIRE', 1213, 0);
|
||||
|
||||
-- Électricité (1221)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO-QUEBEC', 1221, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HYDRO QUEBEC', 1221, 0);
|
||||
|
||||
-- Gaz (1222)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ENERGIR', 1222, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAZ METRO', 1222, 0);
|
||||
|
||||
-- Internet (1231)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIDEOTRON', 1231, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL INTERNET', 1231, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORICOM', 1231, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COGECO', 1231, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EBOX', 1231, 0);
|
||||
|
||||
-- Mobile (1232)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIZZ', 1232, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('KOODO', 1232, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PUBLIC MOBILE', 1232, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('VIRGIN', 1232, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELL MOBILITE', 1232, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TELUS MOBILE', 1232, 0);
|
||||
|
||||
-- Entretien maison (1241)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('IKEA', 1241, 0);
|
||||
|
||||
-- Matériaux & outils (1243)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANADIAN TIRE', 1243, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CANAC', 1243, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RONA', 1243, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HOME DEPOT', 1243, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BMR', 1243, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRINCESS AUTO', 1243, 0);
|
||||
|
||||
-- Assurance habitation (1250)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BELAIR', 1250, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRYSM', 1250, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('INTACT ASSURANCE', 1250, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DESJARDINS ASSURANCE', 1250, 0);
|
||||
|
||||
-- Meubles (1311)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TANGUAY', 1311, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEON', 1311, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STRUCTUBE', 1311, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BRICK', 1311, 0);
|
||||
|
||||
-- Électroménagers (1312)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BEST BUY', 1312, 0);
|
||||
|
||||
-- Décoration (1313)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOUCLAIR', 1313, 0);
|
||||
|
||||
-- Vêtements (1410)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIQLO', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WINNERS', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SIMONS', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARKS', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('H&M', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('OLD NAVY', 1410, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GAP', 1410, 0);
|
||||
|
||||
-- Transport — Essence (1512)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SHELL', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ESSO', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ULTRAMAR', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO-CANADA', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PETRO CANADA', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CREVIER', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HARNOIS', 1512, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('COUCHE-TARD ESSENCE', 1512, 10);
|
||||
|
||||
-- Permis / SAAQ (1514)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAAQ', 1514, 0);
|
||||
|
||||
-- Transport public — autobus/métro (1521)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STM', 1521, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RTC', 1521, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STL', 1521, 0);
|
||||
|
||||
-- Train de banlieue (1522)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE MONT-SAINT', 1522, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE SAINT-HUBERT', 1522, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GARE CENTRALE', 1522, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EXO', 1522, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('REM', 1522, 0);
|
||||
|
||||
-- Taxi / Uber (1523)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UBER', 1523, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LYFT', 1523, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TAXI', 1523, 0);
|
||||
|
||||
-- Avion (1531)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR CANADA', 1531, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WESTJET', 1531, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIR TRANSAT', 1531, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PORTER', 1531, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AEROPORTS DE MONTREAL', 1531, 0);
|
||||
|
||||
-- Hébergement (1533)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AIRBNB', 1533, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('HILTON', 1533, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MARRIOTT', 1533, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BOOKING.COM', 1533, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NORWEGIAN CRUISE', 1533, 0);
|
||||
|
||||
-- Pharmacie (1611)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('JEAN COUTU', 1611, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FAMILIPRIX', 1611, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHARMAPRIX', 1611, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROXIM', 1611, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('UNIPRIX', 1611, 0);
|
||||
|
||||
-- Thérapies (1615)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PHYSIOACTIF', 1615, 0);
|
||||
|
||||
-- Cinéma & spectacles (1711)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEPLEX', 1711, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CINEMA DU PARC', 1711, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TICKETMASTER', 1711, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CLUB SODA', 1711, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEPOINTDEVENTE', 1711, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('EVENBRITE', 1711, 0);
|
||||
|
||||
-- Jeux vidéo (1712)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('STEAMGAMES', 1712, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PLAYSTATION', 1712, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NINTENDO', 1712, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('XBOX', 1712, 0);
|
||||
|
||||
-- Streaming vidéo (1713)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('NETFLIX', 1713, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PRIMEVIDEO', 1713, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DISNEY PLUS', 1713, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRAVE', 1713, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE TV', 1713, 0);
|
||||
|
||||
-- Streaming musique (1714)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPOTIFY', 1714, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('APPLE MUSIC', 1714, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('YOUTUBE MUSIC', 1714, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TIDAL', 1714, 0);
|
||||
|
||||
-- Jouets & hobbies (1715)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LEGO', 1715, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('TOYS R US', 1715, 0);
|
||||
|
||||
-- Équipement sportif (1722)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MOUNTAIN EQUIPMENT', 1722, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('LA CORDEE', 1722, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DECATHLON', 1722, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SPORTS EXPERTS', 1722, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ATMOSPHERE', 1722, 0);
|
||||
|
||||
-- Parcs & activités (1723)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SEPAQ', 1723, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('BLOC SHOP', 1723, 0);
|
||||
|
||||
-- Lecture (1741)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('RENAUD-BRAY', 1741, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('INDIGO', 1741, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARCHAMBAULT', 1741, 0);
|
||||
|
||||
-- Animaux — nourriture (1751)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('MONDOU', 1751, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PET SMART', 1751, 0);
|
||||
|
||||
-- Alcool (1810)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SAQ', 1810, 0);
|
||||
|
||||
-- Cannabis (1820)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('SQDC', 1820, 0);
|
||||
|
||||
-- Frais bancaires (1911)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PROGRAMME PERFORMANCE', 1911, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FRAIS MENSUELS', 1911, 0);
|
||||
|
||||
-- Impôts (1921, 1922)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('GOUV. QUEBEC', 1922, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('REVENU QUEBEC', 1922, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ARC IMPOT', 1921, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CRA TAX', 1921, 0);
|
||||
|
||||
-- Dons de charité (1931)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('OXFAM', 1931, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CENTRAIDE', 1931, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FPA', 1931, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CROIX-ROUGE', 1931, 0);
|
||||
|
||||
-- Cotisations professionnelles (1932)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('ORDRE DES COMPTABL', 1932, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('CPA CANADA', 1932, 0);
|
||||
|
||||
-- Cadeaux (1940)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DANS UN JARDIN', 1940, 0);
|
||||
|
||||
-- Divers (1946) — catch-all marketplace
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMAZON', 1946, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AMZN', 1946, 0);
|
||||
|
||||
-- Placements (1964)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WS INVESTMENTS', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('WEALTHSIMPLE', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PEAK INVESTMENT', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DYNAMIC FUND', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('FIDELITY', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('AGF', 1964, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('QUESTRADE', 1964, 0);
|
||||
|
||||
-- Paie (1011)
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAY/PAY', 1011, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('DEPOT PAIE', 1011, 0);
|
||||
INSERT INTO keywords (keyword, category_id, priority) VALUES ('PAYROLL', 1011, 0);
|
||||
232
src-tauri/src/database/fixtures/seed_v0.3.0.sql
Normal file
232
src-tauri/src/database/fixtures/seed_v0.3.0.sql
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
-- Migration v2: Seed categories and keywords from SommaireDepense.csv
|
||||
|
||||
-- Clear existing data (FK order: keywords -> categories)
|
||||
DELETE FROM keywords;
|
||||
UPDATE transactions SET category_id = NULL;
|
||||
DELETE FROM categories;
|
||||
|
||||
-- ==========================================
|
||||
-- Parent categories (Regroupement level)
|
||||
-- ==========================================
|
||||
INSERT INTO categories (id, name, type, sort_order) VALUES (1, 'Revenus', 'income', 1);
|
||||
INSERT INTO categories (id, name, type, sort_order) VALUES (2, 'Dépenses récurrentes', 'expense', 2);
|
||||
INSERT INTO categories (id, name, type, sort_order) VALUES (3, 'Dépenses ponctuelles', 'expense', 3);
|
||||
INSERT INTO categories (id, name, type, sort_order) VALUES (4, 'Maison', 'expense', 4);
|
||||
INSERT INTO categories (id, name, type, sort_order) VALUES (5, 'Placements', 'transfer', 5);
|
||||
INSERT INTO categories (id, name, type, sort_order) VALUES (6, 'Autres', 'expense', 6);
|
||||
|
||||
-- ==========================================
|
||||
-- Child categories
|
||||
-- ==========================================
|
||||
|
||||
-- Revenus children
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (10, 'Paie', 1, 'income', '#22c55e', 1);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (11, 'Autres revenus', 1, 'income', '#4ade80', 2);
|
||||
|
||||
-- Dépenses récurrentes children
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (20, 'Loyer', 2, 'expense', '#ef4444', 1);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (21, 'Électricité', 2, 'expense', '#f59e0b', 2);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (22, 'Épicerie', 2, 'expense', '#10b981', 3);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (23, 'Dons', 2, 'expense', '#ec4899', 4);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (24, 'Restaurant', 2, 'expense', '#f97316', 5);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (25, 'Frais bancaires', 2, 'expense', '#6b7280', 6);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (26, 'Jeux, Films & Livres', 2, 'expense', '#8b5cf6', 7);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (27, 'Abonnements Musique', 2, 'expense', '#06b6d4', 8);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (28, 'Transport en commun', 2, 'expense', '#3b82f6', 9);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (29, 'Internet & Télécom', 2, 'expense', '#6366f1', 10);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (30, 'Animaux', 2, 'expense', '#a855f7', 11);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (31, 'Assurances', 2, 'expense', '#14b8a6', 12);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (32, 'Pharmacie', 2, 'expense', '#f43f5e', 13);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (33, 'Taxes municipales', 2, 'expense', '#78716c', 14);
|
||||
|
||||
-- Dépenses ponctuelles children
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (40, 'Voiture', 3, 'expense', '#64748b', 1);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (41, 'Amazon', 3, 'expense', '#f59e0b', 2);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (42, 'Électroniques', 3, 'expense', '#3b82f6', 3);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (43, 'Alcool', 3, 'expense', '#7c3aed', 4);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (44, 'Cadeaux', 3, 'expense', '#ec4899', 5);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (45, 'Vêtements', 3, 'expense', '#d946ef', 6);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (46, 'CPA', 3, 'expense', '#0ea5e9', 7);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (47, 'Voyage', 3, 'expense', '#f97316', 8);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (48, 'Sports & Plein air', 3, 'expense', '#22c55e', 9);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (49, 'Spectacles & sorties', 3, 'expense', '#e11d48', 10);
|
||||
|
||||
-- Maison children
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (50, 'Hypothèque', 4, 'expense', '#dc2626', 1);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (51, 'Achats maison', 4, 'expense', '#ea580c', 2);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (52, 'Entretien maison', 4, 'expense', '#ca8a04', 3);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (53, 'Électroménagers & Meubles', 4, 'expense', '#0d9488', 4);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (54, 'Outils', 4, 'expense', '#b45309', 5);
|
||||
|
||||
-- Placements children
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (60, 'Placements', 5, 'transfer', '#2563eb', 1);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (61, 'Transferts', 5, 'transfer', '#7c3aed', 2);
|
||||
|
||||
-- Autres children
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (70, 'Impôts', 6, 'expense', '#dc2626', 1);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (71, 'Paiement CC', 6, 'transfer', '#6b7280', 2);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (72, 'Retrait cash', 6, 'expense', '#57534e', 3);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (73, 'Projets', 6, 'expense', '#0ea5e9', 4);
|
||||
|
||||
-- ==========================================
|
||||
-- Keywords
|
||||
-- ==========================================
|
||||
|
||||
-- Paie (10)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('PAY/PAY', 10);
|
||||
|
||||
-- Électricité (21)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('HYDRO-QUEBEC', 21);
|
||||
|
||||
-- Épicerie (22)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('METRO', 22);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('IGA', 22);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('MAXI', 22);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('SUPER C', 22);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('BOUCHERIE LAFLECHE', 22);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('BOULANGERIE JARRY', 22);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('DOLLARAMA', 22);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('WALMART', 22);
|
||||
|
||||
-- Dons (23)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('OXFAM', 23);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('CENTRAIDE', 23);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('FPA', 23);
|
||||
|
||||
-- Restaurant (24)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('SUBWAY', 24);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('MCDONALD', 24);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('A&W', 24);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('DD/DOORDASH', 24);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('DOORDASH', 24);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('SUSHI', 24);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('DOMINOS', 24);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('BELLE PROVINCE', 24);
|
||||
|
||||
-- Frais bancaires (25)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('PROGRAMME PERFORMANCE', 25);
|
||||
|
||||
-- Jeux, Films & Livres (26)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('STEAMGAMES', 26);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('PLAYSTATION', 26);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('PRIMEVIDEO', 26);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('NINTENDO', 26);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('RENAUD-BRAY', 26);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('CINEMA DU PARC', 26);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('LEGO', 26);
|
||||
|
||||
-- Abonnements Musique (27)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('SPOTIFY', 27);
|
||||
|
||||
-- Transport en commun (28)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('STM', 28);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('GARE MONT-SAINT', 28);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('GARE SAINT-HUBERT', 28);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('GARE CENTRALE', 28);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('REM', 28);
|
||||
|
||||
-- Internet & Télécom (29)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('VIDEOTRON', 29);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('ORICOM', 29);
|
||||
|
||||
-- Animaux (30)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('MONDOU', 30);
|
||||
|
||||
-- Assurances (31)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('BELAIR', 31);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('PRYSM', 31);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('INS/ASS', 31);
|
||||
|
||||
-- Pharmacie (32)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('JEAN COUTU', 32);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('FAMILIPRIX', 32);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('PHARMAPRIX', 32);
|
||||
|
||||
-- Taxes municipales (33)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('M-ST-HILAIRE TX', 33);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('CSS PATRIOT', 33);
|
||||
|
||||
-- Voiture (40)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('SHELL', 40);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('ESSO', 40);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('ULTRAMAR', 40);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('PETRO-CANADA', 40);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('SAAQ', 40);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('CREVIER', 40);
|
||||
|
||||
-- Amazon (41)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('AMAZON', 41);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('AMZN', 41);
|
||||
|
||||
-- Électroniques (42)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('MICROSOFT', 42);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('ADDISON ELECTRONIQUE', 42);
|
||||
|
||||
-- Alcool (43)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('SAQ', 43);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('SQDC', 43);
|
||||
|
||||
-- Cadeaux (44)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('DANS UN JARDIN', 44);
|
||||
|
||||
-- Vêtements (45)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('UNIQLO', 45);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('WINNERS', 45);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('SIMONS', 45);
|
||||
|
||||
-- CPA (46)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('ORDRE DES COMPTABL', 46);
|
||||
|
||||
-- Voyage (47)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('NORWEGIAN CRUISE', 47);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('AEROPORTS DE MONTREAL', 47);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('HILTON', 47);
|
||||
|
||||
-- Sports & Plein air (48)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('BLOC SHOP', 48);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('SEPAQ', 48);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('LA CORDEE', 48);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('MOUNTAIN EQUIPMENT', 48);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('PHYSIOACTIF', 48);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('DECATHLON', 48);
|
||||
|
||||
-- Spectacles & sorties (49)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('TICKETMASTER', 49);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('CLUB SODA', 49);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('LEPOINTDEVENTE', 49);
|
||||
|
||||
-- Hypothèque (50)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('MTG/HYP', 50);
|
||||
|
||||
-- Achats maison (51)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('CANADIAN TIRE', 51);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('CANAC', 51);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('RONA', 51);
|
||||
|
||||
-- Entretien maison (52)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('IKEA', 52);
|
||||
|
||||
-- Électroménagers & Meubles (53)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('TANGUAY', 53);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('BOUCLAIR', 53);
|
||||
|
||||
-- Outils (54)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('BMR', 54);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('HOME DEPOT', 54);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('PRINCESS AUTO', 54);
|
||||
|
||||
-- Placements (60)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('DYNAMIC FUND', 60);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('FIDELITY', 60);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('AGF', 60);
|
||||
|
||||
-- Transferts (61)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('WS INVESTMENTS', 61);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('PEAK INVESTMENT', 61);
|
||||
|
||||
-- Impôts (70)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('GOUV. QUEBEC', 70);
|
||||
|
||||
-- Projets (73)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('CLAUDE.AI', 73);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('NAME-CHEAP', 73);
|
||||
241
src-tauri/src/database/fixtures/seed_v0.4.0.sql
Normal file
241
src-tauri/src/database/fixtures/seed_v0.4.0.sql
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
-- Migration v2: Seed categories and keywords from SommaireDepense.csv
|
||||
|
||||
-- Clear existing data (FK order: keywords -> categories)
|
||||
DELETE FROM keywords;
|
||||
UPDATE transactions SET category_id = NULL;
|
||||
DELETE FROM categories;
|
||||
|
||||
-- ==========================================
|
||||
-- Parent categories (Regroupement level)
|
||||
-- ==========================================
|
||||
INSERT INTO categories (id, name, type, sort_order) VALUES (1, 'Revenus', 'income', 1);
|
||||
INSERT INTO categories (id, name, type, sort_order) VALUES (2, 'Dépenses récurrentes', 'expense', 2);
|
||||
INSERT INTO categories (id, name, type, sort_order) VALUES (3, 'Dépenses ponctuelles', 'expense', 3);
|
||||
INSERT INTO categories (id, name, type, sort_order) VALUES (4, 'Maison', 'expense', 4);
|
||||
INSERT INTO categories (id, name, type, sort_order) VALUES (5, 'Placements', 'transfer', 5);
|
||||
INSERT INTO categories (id, name, type, sort_order) VALUES (6, 'Autres', 'expense', 6);
|
||||
|
||||
-- ==========================================
|
||||
-- Child categories
|
||||
-- ==========================================
|
||||
|
||||
-- Revenus children
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (10, 'Paie', 1, 'income', '#22c55e', 1);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (11, 'Autres revenus', 1, 'income', '#4ade80', 2);
|
||||
|
||||
-- Dépenses récurrentes children
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (20, 'Loyer', 2, 'expense', '#ef4444', 1);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (21, 'Électricité', 2, 'expense', '#f59e0b', 2);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (22, 'Épicerie', 2, 'expense', '#10b981', 3);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (23, 'Dons', 2, 'expense', '#ec4899', 4);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (24, 'Restaurant', 2, 'expense', '#f97316', 5);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (25, 'Frais bancaires', 2, 'expense', '#6b7280', 6);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (26, 'Jeux, Films & Livres', 2, 'expense', '#8b5cf6', 7);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (27, 'Abonnements Musique', 2, 'expense', '#06b6d4', 8);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (28, 'Transport en commun', 2, 'expense', '#3b82f6', 9);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (29, 'Internet & Télécom', 2, 'expense', '#6366f1', 10);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (30, 'Animaux', 2, 'expense', '#a855f7', 11);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order, is_inputable) VALUES (31, 'Assurances', 2, 'expense', '#14b8a6', 12, 0);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (32, 'Pharmacie', 2, 'expense', '#f43f5e', 13);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (33, 'Taxes municipales', 2, 'expense', '#78716c', 14);
|
||||
|
||||
-- Dépenses ponctuelles children
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (40, 'Voiture', 3, 'expense', '#64748b', 1);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (41, 'Amazon', 3, 'expense', '#f59e0b', 2);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (42, 'Électroniques', 3, 'expense', '#3b82f6', 3);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (43, 'Alcool', 3, 'expense', '#7c3aed', 4);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (44, 'Cadeaux', 3, 'expense', '#ec4899', 5);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (45, 'Vêtements', 3, 'expense', '#d946ef', 6);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (46, 'CPA', 3, 'expense', '#0ea5e9', 7);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (47, 'Voyage', 3, 'expense', '#f97316', 8);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (48, 'Sports & Plein air', 3, 'expense', '#22c55e', 9);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (49, 'Spectacles & sorties', 3, 'expense', '#e11d48', 10);
|
||||
|
||||
-- Maison children
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (50, 'Hypothèque', 4, 'expense', '#dc2626', 1);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (51, 'Achats maison', 4, 'expense', '#ea580c', 2);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (52, 'Entretien maison', 4, 'expense', '#ca8a04', 3);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (53, 'Électroménagers & Meubles', 4, 'expense', '#0d9488', 4);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (54, 'Outils', 4, 'expense', '#b45309', 5);
|
||||
|
||||
-- Placements children
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (60, 'Placements', 5, 'transfer', '#2563eb', 1);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (61, 'Transferts', 5, 'transfer', '#7c3aed', 2);
|
||||
|
||||
-- Autres children
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (70, 'Impôts', 6, 'expense', '#dc2626', 1);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (71, 'Paiement CC', 6, 'transfer', '#6b7280', 2);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (72, 'Retrait cash', 6, 'expense', '#57534e', 3);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (73, 'Projets', 6, 'expense', '#0ea5e9', 4);
|
||||
|
||||
-- ==========================================
|
||||
-- Grandchild categories (Level 3 — under Assurances)
|
||||
-- ==========================================
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (310, 'Assurance-auto', 31, 'expense', '#14b8a6', 1);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (311, 'Assurance-habitation', 31, 'expense', '#0d9488', 2);
|
||||
INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES (312, 'Assurance-vie', 31, 'expense', '#0f766e', 3);
|
||||
|
||||
-- ==========================================
|
||||
-- Keywords
|
||||
-- ==========================================
|
||||
|
||||
-- Paie (10)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('PAY/PAY', 10);
|
||||
|
||||
-- Électricité (21)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('HYDRO-QUEBEC', 21);
|
||||
|
||||
-- Épicerie (22)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('METRO', 22);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('IGA', 22);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('MAXI', 22);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('SUPER C', 22);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('BOUCHERIE LAFLECHE', 22);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('BOULANGERIE JARRY', 22);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('DOLLARAMA', 22);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('WALMART', 22);
|
||||
|
||||
-- Dons (23)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('OXFAM', 23);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('CENTRAIDE', 23);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('FPA', 23);
|
||||
|
||||
-- Restaurant (24)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('SUBWAY', 24);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('MCDONALD', 24);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('A&W', 24);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('DD/DOORDASH', 24);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('DOORDASH', 24);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('SUSHI', 24);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('DOMINOS', 24);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('BELLE PROVINCE', 24);
|
||||
|
||||
-- Frais bancaires (25)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('PROGRAMME PERFORMANCE', 25);
|
||||
|
||||
-- Jeux, Films & Livres (26)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('STEAMGAMES', 26);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('PLAYSTATION', 26);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('PRIMEVIDEO', 26);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('NINTENDO', 26);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('RENAUD-BRAY', 26);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('CINEMA DU PARC', 26);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('LEGO', 26);
|
||||
|
||||
-- Abonnements Musique (27)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('SPOTIFY', 27);
|
||||
|
||||
-- Transport en commun (28)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('STM', 28);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('GARE MONT-SAINT', 28);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('GARE SAINT-HUBERT', 28);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('GARE CENTRALE', 28);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('REM', 28);
|
||||
|
||||
-- Internet & Télécom (29)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('VIDEOTRON', 29);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('ORICOM', 29);
|
||||
|
||||
-- Animaux (30)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('MONDOU', 30);
|
||||
|
||||
-- Assurance-auto (310)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('BELAIR', 310);
|
||||
-- Assurance-habitation (311)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('PRYSM', 311);
|
||||
-- Assurance-vie (312)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('INS/ASS', 312);
|
||||
|
||||
-- Pharmacie (32)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('JEAN COUTU', 32);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('FAMILIPRIX', 32);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('PHARMAPRIX', 32);
|
||||
|
||||
-- Taxes municipales (33)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('M-ST-HILAIRE TX', 33);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('CSS PATRIOT', 33);
|
||||
|
||||
-- Voiture (40)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('SHELL', 40);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('ESSO', 40);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('ULTRAMAR', 40);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('PETRO-CANADA', 40);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('SAAQ', 40);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('CREVIER', 40);
|
||||
|
||||
-- Amazon (41)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('AMAZON', 41);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('AMZN', 41);
|
||||
|
||||
-- Électroniques (42)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('MICROSOFT', 42);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('ADDISON ELECTRONIQUE', 42);
|
||||
|
||||
-- Alcool (43)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('SAQ', 43);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('SQDC', 43);
|
||||
|
||||
-- Cadeaux (44)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('DANS UN JARDIN', 44);
|
||||
|
||||
-- Vêtements (45)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('UNIQLO', 45);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('WINNERS', 45);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('SIMONS', 45);
|
||||
|
||||
-- CPA (46)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('ORDRE DES COMPTABL', 46);
|
||||
|
||||
-- Voyage (47)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('NORWEGIAN CRUISE', 47);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('AEROPORTS DE MONTREAL', 47);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('HILTON', 47);
|
||||
|
||||
-- Sports & Plein air (48)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('BLOC SHOP', 48);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('SEPAQ', 48);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('LA CORDEE', 48);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('MOUNTAIN EQUIPMENT', 48);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('PHYSIOACTIF', 48);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('DECATHLON', 48);
|
||||
|
||||
-- Spectacles & sorties (49)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('TICKETMASTER', 49);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('CLUB SODA', 49);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('LEPOINTDEVENTE', 49);
|
||||
|
||||
-- Hypothèque (50)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('MTG/HYP', 50);
|
||||
|
||||
-- Achats maison (51)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('CANADIAN TIRE', 51);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('CANAC', 51);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('RONA', 51);
|
||||
|
||||
-- Entretien maison (52)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('IKEA', 52);
|
||||
|
||||
-- Électroménagers & Meubles (53)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('TANGUAY', 53);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('BOUCLAIR', 53);
|
||||
|
||||
-- Outils (54)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('BMR', 54);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('HOME DEPOT', 54);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('PRINCESS AUTO', 54);
|
||||
|
||||
-- Placements (60)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('DYNAMIC FUND', 60);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('FIDELITY', 60);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('AGF', 60);
|
||||
|
||||
-- Transferts (61)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('WS INVESTMENTS', 61);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('PEAK INVESTMENT', 61);
|
||||
|
||||
-- Impôts (70)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('GOUV. QUEBEC', 70);
|
||||
|
||||
-- Projets (73)
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('CLAUDE.AI', 73);
|
||||
INSERT INTO keywords (keyword, category_id) VALUES ('NAME-CHEAP', 73);
|
||||
|
|
@ -1,5 +1,11 @@
|
|||
pub mod profile_db;
|
||||
|
||||
pub const SCHEMA: &str = include_str!("schema.sql");
|
||||
pub const SEED_CATEGORIES: &str = include_str!("seed_categories.sql");
|
||||
pub const CONSOLIDATED_SCHEMA: &str = include_str!("consolidated_schema.sql");
|
||||
pub const BALANCE_SCHEMA: &str = include_str!("balance_schema.sql");
|
||||
pub const BALANCE_HOLDINGS_SCHEMA: &str = include_str!("balance_holdings_schema.sql");
|
||||
|
||||
/// File name of the default profile's database: the only one tauri-plugin-sql
|
||||
/// migrates itself (`add_migrations` in lib.rs).
|
||||
pub const DEFAULT_PROFILE_DB: &str = "simpl_resultat.db";
|
||||
|
|
|
|||
1802
src-tauri/src/database/profile_db.rs
Normal file
1802
src-tauri/src/database/profile_db.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -6,9 +6,12 @@ use tauri::{Emitter, Manager};
|
|||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let migrations = vec![
|
||||
/// Every migration, in order. tauri-plugin-sql applies them to the default
|
||||
/// profile on load; `database::profile_db` catches the secondary profiles up
|
||||
/// with the same list (#390). The SQL bytes are checksummed by sqlx and pinned
|
||||
/// by a test: never edit an existing entry, append a new version.
|
||||
pub(crate) fn migrations() -> Vec<Migration> {
|
||||
vec![
|
||||
Migration {
|
||||
version: 1,
|
||||
description: "create initial schema",
|
||||
|
|
@ -365,7 +368,12 @@ pub fn run() {
|
|||
WHERE column_mapping LIKE '%debitAmount%';",
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
];
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let migrations = migrations();
|
||||
|
||||
tauri::Builder::default()
|
||||
// Single-instance plugin MUST be registered first. With the `deep-link`
|
||||
|
|
@ -429,7 +437,7 @@ pub fn run() {
|
|||
})
|
||||
.plugin(
|
||||
tauri_plugin_sql::Builder::default()
|
||||
.add_migrations("sqlite:simpl_resultat.db", migrations)
|
||||
.add_migrations(&format!("sqlite:{}", database::DEFAULT_PROFILE_DB), migrations)
|
||||
.build(),
|
||||
)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
|
|
@ -439,6 +447,7 @@ pub fn run() {
|
|||
commands::detect_encoding,
|
||||
commands::get_file_preview,
|
||||
commands::pick_folder,
|
||||
commands::create_sample_import_folders,
|
||||
commands::pick_save_file,
|
||||
commands::pick_import_file,
|
||||
commands::write_export_file,
|
||||
|
|
@ -451,17 +460,19 @@ pub fn run() {
|
|||
commands::hash_pin,
|
||||
commands::verify_pin,
|
||||
commands::repair_migrations,
|
||||
commands::prepare_profile_db,
|
||||
commands::validate_license_key,
|
||||
commands::store_license,
|
||||
commands::remove_license,
|
||||
commands::store_activation_token,
|
||||
commands::read_license,
|
||||
commands::get_edition,
|
||||
commands::get_machine_id,
|
||||
commands::check_entitlement,
|
||||
commands::activate_machine,
|
||||
commands::deactivate_machine,
|
||||
commands::list_activated_machines,
|
||||
commands::get_activation_status,
|
||||
commands::check_license_revocation,
|
||||
commands::start_oauth,
|
||||
commands::refresh_auth_token,
|
||||
commands::get_account_info,
|
||||
|
|
@ -3865,12 +3876,10 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn consolidated_schema_matches_v17_chain_on_import_sources_at_parity() {
|
||||
// The consolidated schema is the tested reference definition for new
|
||||
// profiles. Without this test the DEFAULT or the CHECK of the four v17
|
||||
// columns could drift between the two definitions unnoticed — the
|
||||
// columns are inert on the production path (this script runs after every
|
||||
// migration and only uses CREATE TABLE IF NOT EXISTS), so nothing else
|
||||
// would surface the divergence.
|
||||
// The consolidated schema is the definition a secondary profile is
|
||||
// created with: tauri-plugin-sql runs no migration on its database
|
||||
// (#390). Without this test the DEFAULT or the CHECK of the four v17
|
||||
// columns could drift between the two definitions unnoticed.
|
||||
let chain = db_pre_v17();
|
||||
chain.execute_batch(V17_SQL).expect("apply v17");
|
||||
let consolidated = consolidated_db();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Simpl Resultat",
|
||||
"version": "0.15.0",
|
||||
"version": "0.17.2",
|
||||
"identifier": "com.simpl.resultat",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
|
|
|||
51
src/App.tsx
51
src/App.tsx
|
|
@ -1,5 +1,5 @@
|
|||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useProfile } from "./contexts/ProfileContext";
|
||||
import AppShell from "./components/layout/AppShell";
|
||||
|
|
@ -30,55 +30,22 @@ import ChangelogPage from "./pages/ChangelogPage";
|
|||
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
|
||||
import ErrorPage from "./components/shared/ErrorPage";
|
||||
import RequireFeature from "./components/shared/RequireFeature";
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 10_000;
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY_MS = 1_000;
|
||||
import { startProfileConnection } from "./utils/startupConnection";
|
||||
|
||||
export default function App() {
|
||||
const { t } = useTranslation();
|
||||
const { activeProfile, isLoading, refreshKey, connectActiveProfile } = useProfile();
|
||||
const [dbReady, setDbReady] = useState(false);
|
||||
const [startupError, setStartupError] = useState<string | null>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeProfile && !isLoading) {
|
||||
setDbReady(false);
|
||||
setStartupError(null);
|
||||
cancelledRef.current = false;
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setStartupError(t("error.startupTimeout"));
|
||||
}, STARTUP_TIMEOUT_MS);
|
||||
|
||||
const attemptConnect = async (attempt: number): Promise<void> => {
|
||||
try {
|
||||
await connectActiveProfile();
|
||||
if (cancelledRef.current) return;
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setDbReady(true);
|
||||
} catch (err) {
|
||||
if (cancelledRef.current) return;
|
||||
console.error(`Failed to connect profile (attempt ${attempt}/${MAX_RETRIES}):`, err);
|
||||
if (attempt < MAX_RETRIES) {
|
||||
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
||||
if (!cancelledRef.current) return attemptConnect(attempt + 1);
|
||||
} else {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setStartupError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
attemptConnect(1);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
if (!activeProfile || isLoading) return;
|
||||
return startProfileConnection({
|
||||
connect: connectActiveProfile,
|
||||
setDbReady,
|
||||
setStartupError,
|
||||
timeoutMessage: t("error.startupTimeout"),
|
||||
});
|
||||
}, [activeProfile, isLoading, connectActiveProfile, t]);
|
||||
|
||||
if (isLoading) {
|
||||
|
|
|
|||
41
src/__fixtures__/samples/exemple-desjardins.csv
Normal file
41
src/__fixtures__/samples/exemple-desjardins.csv
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
Date;Description;Montant;Solde
|
||||
01/{{M2}};DEPOT PAIE EMPLOYEUR;1842,50;4258,30
|
||||
01/{{M2}};MTG/HYP PRET HYPOTHECAIRE;-1236,00;3022,30
|
||||
02/{{M2}};HYDRO QUEBEC PREAUTORISE;-118,42;2903,88
|
||||
03/{{M2}};METRO PLUS ST-LAMBERT;-96,18;2807,70
|
||||
05/{{M2}};VIDEOTRON;-89,95;2717,75
|
||||
06/{{M2}};STM OPUS EN LIGNE;-97,00;2620,75
|
||||
08/{{M2}};IGA EXTRA;-132,47;2488,28
|
||||
09/{{M2}};SHELL BOUL TASCHEREAU;-62,30;2425,98
|
||||
10/{{M2}};FIZZ MOBILE;-34,49;2391,49
|
||||
12/{{M2}};JEAN COUTU;-27,85;2363,64
|
||||
13/{{M2}};GARDERIE LES PETITS PAS;-410,00;1953,64
|
||||
15/{{M2}};DEPOT PAIE EMPLOYEUR;1842,50;3796,14
|
||||
16/{{M2}};BELAIR DIRECT ASSURANCE;-94,60;3701,54
|
||||
17/{{M2}};COSTCO WHOLESALE;-218,73;3482,81
|
||||
19/{{M2}};VIREMENT INTERAC RECU;120,00;3602,81
|
||||
20/{{M2}};PAIEMENT VISA TANGERINE;-650,00;2952,81
|
||||
22/{{M2}};CLINIQUE DENTAIRE ST-LAMBERT;-185,00;2767,81
|
||||
24/{{M2}};METRO PLUS ST-LAMBERT;-74,62;2693,19
|
||||
27/{{M2}};RESTAURANT LA BANQUISE;-48,90;2644,29
|
||||
28/{{M2}};FRAIS MENSUELS;-6,95;2637,34
|
||||
01/{{M1}};DEPOT PAIE EMPLOYEUR;1842,50;4479,84
|
||||
01/{{M1}};MTG/HYP PRET HYPOTHECAIRE;-1236,00;3243,84
|
||||
02/{{M1}};HYDRO QUEBEC PREAUTORISE;-104,17;3139,67
|
||||
04/{{M1}};METRO PLUS ST-LAMBERT;-112,36;3027,31
|
||||
05/{{M1}};VIDEOTRON;-89,95;2937,36
|
||||
06/{{M1}};STM OPUS EN LIGNE;-97,00;2840,36
|
||||
07/{{M1}};MAXI ET CIE;-88,41;2751,95
|
||||
09/{{M1}};ULTRAMAR;-58,75;2693,20
|
||||
10/{{M1}};FIZZ MOBILE;-34,49;2658,71
|
||||
11/{{M1}};FAMILIPRIX;-41,20;2617,51
|
||||
13/{{M1}};GARDERIE LES PETITS PAS;-410,00;2207,51
|
||||
15/{{M1}};DEPOT PAIE EMPLOYEUR;1842,50;4050,01
|
||||
16/{{M1}};BELAIR DIRECT ASSURANCE;-94,60;3955,41
|
||||
18/{{M1}};IGA EXTRA;-147,09;3808,32
|
||||
19/{{M1}};RETRAITE QUEBEC ALLOC FAM;232,00;4040,32
|
||||
20/{{M1}};PAIEMENT VISA TANGERINE;-712,00;3328,32
|
||||
21/{{M1}};CANAC;-63,28;3265,04
|
||||
23/{{M1}};METRO PLUS ST-LAMBERT;-69,84;3195,20
|
||||
26/{{M1}};SUSHI SHOP;-32,45;3162,75
|
||||
28/{{M1}};FRAIS MENSUELS;-6,95;3155,80
|
||||
|
41
src/__fixtures__/samples/exemple-tangerine.csv
Normal file
41
src/__fixtures__/samples/exemple-tangerine.csv
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
Date,Transaction,Name,Memo,Amount
|
||||
02/{{M2}},DEBIT,COSTCO WHOLESALE,,-164.32
|
||||
03/{{M2}},DEBIT,TIM HORTONS,,-6.45
|
||||
04/{{M2}},DEBIT,NETFLIX.COM,,-20.99
|
||||
05/{{M2}},DEBIT,AMAZON.CA,,-47.88
|
||||
06/{{M2}},DEBIT,DOLLARAMA,,-18.35
|
||||
08/{{M2}},DEBIT,STARBUCKS,,-7.90
|
||||
09/{{M2}},DEBIT,DOORDASH,,-38.64
|
||||
10/{{M2}},DEBIT,SPOTIFY,,-11.99
|
||||
11/{{M2}},DEBIT,UNIQLO,,-89.95
|
||||
12/{{M2}},DEBIT,SAQ,,-43.50
|
||||
14/{{M2}},DEBIT,MCDONALD'S,,-14.27
|
||||
15/{{M2}},DEBIT,PHARMAPRIX,,-31.18
|
||||
16/{{M2}},DEBIT,CANADIAN TIRE,,-76.42
|
||||
18/{{M2}},DEBIT,RESTAURANT LE PETIT ITALIEN,,-72.30
|
||||
19/{{M2}},DEBIT,ESSO,,-61.05
|
||||
20/{{M2}},CREDIT,PAIEMENT - MERCI,,650.00
|
||||
21/{{M2}},DEBIT,SEPAQ,,-38.00
|
||||
23/{{M2}},DEBIT,RENAUD-BRAY,,-29.95
|
||||
25/{{M2}},DEBIT,UBER TRIP,,-17.60
|
||||
27/{{M2}},DEBIT,CINEPLEX,,-28.50
|
||||
01/{{M1}},DEBIT,COSTCO WHOLESALE,,-201.17
|
||||
03/{{M1}},DEBIT,TIM HORTONS,,-5.85
|
||||
04/{{M1}},DEBIT,NETFLIX.COM,,-20.99
|
||||
05/{{M1}},DEBIT,AMAZON.CA,,-32.49
|
||||
07/{{M1}},DEBIT,DOLLARAMA,,-12.40
|
||||
08/{{M1}},DEBIT,STARBUCKS,,-8.35
|
||||
09/{{M1}},DEBIT,DOORDASH,,-42.11
|
||||
10/{{M1}},DEBIT,SPOTIFY,,-11.99
|
||||
11/{{M1}},DEBIT,SIMONS,,-124.60
|
||||
12/{{M1}},DEBIT,SAQ,,-38.75
|
||||
13/{{M1}},DEBIT,A&W,,-16.90
|
||||
15/{{M1}},DEBIT,PHARMAPRIX,,-24.66
|
||||
16/{{M1}},DEBIT,RONA,,-58.12
|
||||
17/{{M1}},DEBIT,BRASSERIE LES FRERES,,-64.80
|
||||
19/{{M1}},DEBIT,PETRO-CANADA,,-57.40
|
||||
20/{{M1}},CREDIT,PAIEMENT - MERCI,,712.00
|
||||
22/{{M1}},DEBIT,LA CORDEE,,-89.99
|
||||
24/{{M1}},DEBIT,INDIGO,,-34.50
|
||||
26/{{M1}},DEBIT,UBER TRIP,,-21.15
|
||||
27/{{M1}},DEBIT,MOUNTAIN EQUIPMENT CO,,-45.00
|
||||
|
39
src/__fixtures__/samples/index.ts
Normal file
39
src/__fixtures__/samples/index.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Sample statements the onboarding offers to a new user (issue #354).
|
||||
*
|
||||
* Both files are INVENTED, like the detection corpus in `../csv`: no real
|
||||
* account, no real transaction, no real person. They are the two templates the
|
||||
* Rust command `create_sample_import_folders` embeds with `include_str!` and
|
||||
* writes into the import folder — one Desjardins chequing account, one
|
||||
* Tangerine credit card — with the `{{M1}}` / `{{M2}}` tokens rendered as the
|
||||
* two complete months preceding the day the user clicks.
|
||||
*
|
||||
* This module is deliberately SEPARATE from `../csv/index.ts`: that corpus is
|
||||
* frozen at fifteen cases by `csvAutoDetect.test.ts`, and six loops pass every
|
||||
* `CsvFixtureName` to detection. Adding these two there would silently widen
|
||||
* the frozen contract.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
/** The two sample statements, by bank. */
|
||||
export type SampleTemplateName = "desjardins" | "tangerine";
|
||||
|
||||
const FILES: Record<SampleTemplateName, string> = {
|
||||
desjardins: "exemple-desjardins.csv",
|
||||
tangerine: "exemple-tangerine.csv",
|
||||
};
|
||||
|
||||
/** Raw template text, tokens included — exactly what `include_str!` embeds. */
|
||||
export function readSampleTemplate(name: SampleTemplateName): string {
|
||||
return readFileSync(resolve(import.meta.dirname, FILES[name]), "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a template the way the Rust command does: `{{M1}}` becomes the most
|
||||
* recent complete month, `{{M2}}` the one before, both as `MM/YYYY`.
|
||||
*/
|
||||
export function renderSampleTemplate(raw: string, m1: string, m2: string): string {
|
||||
return raw.replace(/\{\{M1\}\}/g, m1).replace(/\{\{M2\}\}/g, m2);
|
||||
}
|
||||
112
src/__integration__/i18n-mock-guard.test.ts
Normal file
112
src/__integration__/i18n-mock-guard.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* Guard: one fake `t` in the test suite, the marked one of `src/test/i18nMock.ts` (#389).
|
||||
*
|
||||
* A local fake `t` that returns the key as is hides a forgotten call to `t`
|
||||
* (#387 review). The migration moved every test file to `i18nMock`; this guard
|
||||
* keeps it that way. It reads the sources, as `transactions-transfer-icon.test.ts`
|
||||
* does, and fails on any test file (`*.test.ts`, `*.test.tsx`) other than the
|
||||
* module itself that:
|
||||
* 1. declares a `useTranslation:` property (a hand-written `react-i18next` module);
|
||||
* 2. declares a hand-written fake `t`: a `t: (` or `t: vi.fn(` property, or a
|
||||
* `t = (` / `t = vi.fn(` binding (`const t = (key) => key`, passed to a
|
||||
* function that takes `t`);
|
||||
* 3. mocks `react-i18next` without an actual call to `i18nMock(` (the name
|
||||
* alone, in a comment for instance, is not enough: comments are dropped
|
||||
* before the search).
|
||||
*
|
||||
* Keywords rather than an analysis of the factory: less fragile. The matches
|
||||
* are anchored on a word boundary, so `useLicenseContext: () =>`,
|
||||
* `get: (target, key)`, `const at = (` or `t === (` are not a fake `t`. This
|
||||
* file names the patterns only inside regular expressions and excludes itself.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readdirSync, readFileSync } from "fs";
|
||||
import { relative, resolve, sep } from "path";
|
||||
|
||||
const SRC = resolve(import.meta.dirname, "..");
|
||||
const MODULE = resolve(SRC, "test", "i18nMock.ts");
|
||||
const SELF = resolve(import.meta.dirname, "i18n-mock-guard.test.ts");
|
||||
|
||||
const LOCAL_MODULE = /\buseTranslation\s*:/;
|
||||
const LOCAL_T = /\bt\s*[:=]\s*(?:\(|vi\.fn\()/;
|
||||
const MOCKS_I18NEXT = /vi\.mock\(\s*["']react-i18next["']/;
|
||||
const CALLS_I18NMOCK = /\bi18nMock\s*\(/;
|
||||
|
||||
/** The source without its comments, so a mention of `i18nMock(` in one does not count as a call. */
|
||||
function withoutComments(source: string): string {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
/** The offending lines of one source, as `path:line: text`. */
|
||||
function offences(path: string, source: string): string[] {
|
||||
const name = relative(SRC, path).split(sep).join("/");
|
||||
const found = source
|
||||
.split("\n")
|
||||
.flatMap((line, i) =>
|
||||
LOCAL_MODULE.test(line) || LOCAL_T.test(line) ? [`${name}:${i + 1}: ${line.trim()}`] : [],
|
||||
);
|
||||
if (MOCKS_I18NEXT.test(source) && !CALLS_I18NMOCK.test(withoutComments(source))) {
|
||||
found.push(`${name}: mocks react-i18next without i18nMock`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const TEST_FILES = (readdirSync(SRC, { recursive: true }) as string[])
|
||||
.filter((entry) => /\.test\.tsx?$/.test(entry))
|
||||
.map((entry) => resolve(SRC, entry))
|
||||
.filter((path) => path !== MODULE && path !== SELF);
|
||||
|
||||
describe("i18n mock guard (#389)", () => {
|
||||
it("scans the test files of src, recursively", () => {
|
||||
expect(TEST_FILES.length).toBeGreaterThan(100);
|
||||
expect(TEST_FILES).toContain(resolve(SRC, "components", "settings", "LicenseCard.render.test.tsx"));
|
||||
expect(TEST_FILES).toContain(resolve(SRC, "pages", "settings", "UsersSettingsPage.test.tsx"));
|
||||
});
|
||||
|
||||
it("recognises a local fake, and only that", () => {
|
||||
const local = [
|
||||
'vi.mock("react-i18next", () => ({',
|
||||
" useTranslation: () => ({ t: (k: string) => k }),",
|
||||
"}));",
|
||||
].join("\n");
|
||||
expect(offences(resolve(SRC, "x.test.tsx"), local)).toHaveLength(2);
|
||||
expect(offences(resolve(SRC, "x.test.tsx"), 'vi.mock("react-i18next", () => fake);')).toHaveLength(1);
|
||||
const clean = [
|
||||
'vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock());',
|
||||
"const context = { useLicenseContext: () => ctx, get: (target: object) => target };",
|
||||
"await view.removeKey({ confirm, t: markedT });",
|
||||
"const at = (n: number) => n; const format = (v: string) => v;",
|
||||
"if (t === (other as unknown)) return; const run = (t) => t;",
|
||||
"const t = markedT; const set = { t: markedT };",
|
||||
].join("\n");
|
||||
expect(offences(resolve(SRC, "x.test.tsx"), clean)).toEqual([]);
|
||||
});
|
||||
|
||||
it("recognises a fake t bound to a name or built with vi.fn", () => {
|
||||
const x = resolve(SRC, "x.test.ts");
|
||||
expect(offences(x, "const t = (key: string) => key;")).toHaveLength(1);
|
||||
expect(offences(x, "let t = (key: string, o?: object) => key;")).toHaveLength(1);
|
||||
expect(offences(x, " t = (key: string) => key;")).toHaveLength(1);
|
||||
expect(offences(x, "const t = vi.fn((key: string) => key);")).toHaveLength(1);
|
||||
expect(offences(x, " useT: () => ({ t: vi.fn((k: string) => k) }),")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("requires an actual call to i18nMock(, not its name in a comment", () => {
|
||||
const x = resolve(SRC, "x.test.tsx");
|
||||
const inComment = [
|
||||
"// Not i18nMock(): a hand-written module.",
|
||||
"/* i18nMock( would be the pattern */",
|
||||
'vi.mock("react-i18next", () => fake);',
|
||||
].join("\n");
|
||||
expect(offences(x, inComment)).toEqual(["x.test.tsx: mocks react-i18next without i18nMock"]);
|
||||
expect(offences(x, '// i18nMock is the rule\nvi.mock("react-i18next", () => fake);')).toHaveLength(1);
|
||||
const called = 'vi.mock("react-i18next", async () => (await import("../test/i18nMock")).i18nMock({ language: "en" }));';
|
||||
expect(offences(x, called)).toEqual([]);
|
||||
});
|
||||
|
||||
it("finds no other fake t than the one of src/test/i18nMock.ts", () => {
|
||||
const found = TEST_FILES.flatMap((path) => offences(path, readFileSync(path, "utf-8")));
|
||||
expect(found, "use i18nMock from src/test/i18nMock.ts").toEqual([]);
|
||||
});
|
||||
});
|
||||
148
src/__integration__/native-dialog-guard.test.ts
Normal file
148
src/__integration__/native-dialog-guard.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* Guard: no native `confirm` or `alert` in the app, and every confirmation awaited (#421).
|
||||
*
|
||||
* On desktop, tauri-plugin-dialog replaces `window.confirm` with an async function and
|
||||
* `window.alert` with one that does not block. `if (!confirm(...)) return;` then tests a
|
||||
* Promise, always truthy, and the deletion ran before the user answered. TypeScript does not
|
||||
* see it: the DOM type of `confirm` stays `boolean`, and `!promise` is not an error.
|
||||
*
|
||||
* It reads the non-test sources of `src/`, comments removed, as `i18n-mock-guard.test.ts` does,
|
||||
* and fails on:
|
||||
* 1. a native `confirm` or `alert`: called bare or through `window`, `globalThis` or `self`
|
||||
* (a member call such as `deps.confirm(` is not the native one), with `?.(` too, read by
|
||||
* index (`window["confirm"]`) or destructured from one of those objects;
|
||||
* 2. a call to `confirmAction(` or to an injected `confirm(` (`deps.confirm(`,
|
||||
* `ui.confirm(`) that is not directly preceded by `await`;
|
||||
* 3. any other mention of `confirmAction`: an alias (`const c = confirmAction`) or a renamed
|
||||
* import would call it out of reach of rule 2 (#465 review). Allowed besides the awaited
|
||||
* call: its declaration, a plain named import, and `confirm: confirmAction`, the value of
|
||||
* an injected `confirm` whose calls rule 2 checks.
|
||||
* `confirmAction` itself calls the plugin's `ask`, so `src/services/confirmService.ts` is
|
||||
* scanned like any other file. Keywords rather than a parse: the samples below check that each
|
||||
* rule flags what it targets and lets the right form through.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readdirSync, readFileSync } from "fs";
|
||||
import { relative, resolve, sep } from "path";
|
||||
|
||||
const SRC = resolve(import.meta.dirname, "..");
|
||||
|
||||
const NATIVE_CALL = /(?:\b(?:window|globalThis|self)\s*\.\s*|(?<![.\w$]))(?:confirm|alert)\s*(?:\?\.\s*)?\(/;
|
||||
const NATIVE_INDEX = /\b(?:window|globalThis|self)\s*(?:\?\.\s*)?\[\s*["'`](?:confirm|alert)["'`]\s*\]/;
|
||||
const NATIVE_DESTRUCTURED = /\{[^}]*\b(?:confirm|alert)\b[^}]*\}\s*=\s*(?:window|globalThis|self)\b/;
|
||||
// `confirmAction(` or `<receiver>.confirm(`, the receiver captured to skip the native ones.
|
||||
const CONFIRM_CALL = /\b(?:confirmAction|(\w+)\s*\.\s*confirm)\s*\(/g;
|
||||
const NATIVE_RECEIVER = /^(?:window|globalThis|self)$/;
|
||||
const AWAITED = /\bawait\s+$/;
|
||||
const DECLARED = /\bfunction\s+$/;
|
||||
// Mentions of `confirmAction` that rule 3 allows; any other one is an offence.
|
||||
const CONFIRM_ACTION = /\bconfirmAction\b/g;
|
||||
const ALLOWED_MENTION = [
|
||||
/\bawait\s+confirmAction\s*\(/g,
|
||||
/\bfunction\s+confirmAction\s*\(/g,
|
||||
/^\s*import\s*\{\s*confirmAction\s*\}\s*from\s/g,
|
||||
/\bconfirm\s*:\s*confirmAction\s*[,}]/g,
|
||||
];
|
||||
|
||||
/** The source without its comments, line breaks kept so that line numbers stay right. */
|
||||
function withoutComments(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, ""))
|
||||
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
/** The offences of one line: a native call, or a confirmation not awaited. */
|
||||
function lineOffences(line: string): string[] {
|
||||
const found: string[] = [];
|
||||
if (NATIVE_CALL.test(line) || NATIVE_INDEX.test(line) || NATIVE_DESTRUCTURED.test(line)) {
|
||||
found.push("native confirm/alert");
|
||||
}
|
||||
for (const match of line.matchAll(CONFIRM_CALL)) {
|
||||
if (match[1] !== undefined && NATIVE_RECEIVER.test(match[1])) continue;
|
||||
const before = line.slice(0, match.index);
|
||||
if (!AWAITED.test(before) && !DECLARED.test(before)) found.push("confirmation not awaited");
|
||||
}
|
||||
const allowed = ALLOWED_MENTION.reduce((n, rule) => n + [...line.matchAll(rule)].length, 0);
|
||||
if ([...line.matchAll(CONFIRM_ACTION)].length > allowed) found.push("confirmAction out of reach");
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The offending lines of one source, as `path:line: kind: text`. */
|
||||
function offences(path: string, source: string): string[] {
|
||||
const name = relative(SRC, path).split(sep).join("/");
|
||||
return withoutComments(source)
|
||||
.split("\n")
|
||||
.flatMap((line, i) => lineOffences(line).map((kind) => `${name}:${i + 1}: ${kind}: ${line.trim()}`));
|
||||
}
|
||||
|
||||
const SOURCE_FILES = (readdirSync(SRC, { recursive: true }) as string[])
|
||||
.filter((entry) => /\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry) && !entry.endsWith(".d.ts"))
|
||||
.map((entry) => resolve(SRC, entry));
|
||||
|
||||
describe("native dialog guard (#421)", () => {
|
||||
it("scans the app's sources", () => {
|
||||
const names = SOURCE_FILES.map((path) => relative(SRC, path).split(sep).join("/"));
|
||||
expect(names.length).toBeGreaterThan(200);
|
||||
expect(names).toContain("components/profile/ProfileFormModal.tsx");
|
||||
expect(names).toContain("hooks/useLicenseMachines.ts");
|
||||
expect(names).toContain("services/confirmService.ts");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"if (!confirm(t(\"profile.deleteConfirm\"))) return;",
|
||||
"if (confirm(message)) {",
|
||||
"if (!window.confirm(message)) return;",
|
||||
"onClick={() => removeKey({ confirm: (m) => window.confirm(m), t })}",
|
||||
"globalThis.confirm(message);",
|
||||
"self . alert(message);",
|
||||
"alert(message);",
|
||||
"if (!window[\"confirm\"](message)) return;",
|
||||
"globalThis['alert'](message);",
|
||||
"if (!confirm?.(message)) return;",
|
||||
"const { confirm } = window;",
|
||||
"const { alert: show, confirm: ask } = globalThis;",
|
||||
])("flags the native call in %s", (line) => {
|
||||
expect(lineOffences(line)).toContain("native confirm/alert");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"if (!confirmAction(t(\"categories.deleteConfirm\"))) return;",
|
||||
"if (confirmAction(message)) {",
|
||||
"if (!deps.confirm(deps.t(key))) return;",
|
||||
"const ok = ui.confirm(message);",
|
||||
])("flags the confirmation not awaited in %s", (line) => {
|
||||
expect(lineOffences(line)).toContain("confirmation not awaited");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"const c = confirmAction;",
|
||||
"import { confirmAction as ask } from \"../services/confirmService\";",
|
||||
"const handlers = { remove: confirmAction };",
|
||||
"items.map(confirmAction);",
|
||||
"if (await confirmAction(a) && check(confirmAction)) {",
|
||||
])("flags confirmAction out of reach in %s", (line) => {
|
||||
expect(lineOffences(line)).toContain("confirmAction out of reach");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"if (!(await confirmAction(t(\"profile.deleteConfirm\")))) return;",
|
||||
"if (await confirmAction(message)) {",
|
||||
"if (!(await deps.confirm(deps.t(key)))) return;",
|
||||
"const ok = await confirmAction(message);",
|
||||
"onClick={() => void removeKey({ confirm: confirmAction, t })}",
|
||||
"import { confirmAction } from \"../../services/confirmService\";",
|
||||
"export async function confirmAction(message: string): Promise<boolean> {",
|
||||
"if ((await confirmAction(a)) && (await confirmAction(b))) {",
|
||||
"deps.confirm.mockClear();",
|
||||
"const confirmed = await ask(message, { kind: \"warning\" });",
|
||||
"t(\"common.confirm\")",
|
||||
])("lets %s through", (line) => {
|
||||
expect(lineOffences(line)).toEqual([]);
|
||||
});
|
||||
|
||||
it("finds no native dialog and no confirmation left unawaited", () => {
|
||||
const found = SOURCE_FILES.flatMap((path) => offences(path, readFileSync(path, "utf8")));
|
||||
expect(found).toEqual([]);
|
||||
});
|
||||
});
|
||||
265
src/__integration__/onboarding-samples.test.ts
Normal file
265
src/__integration__/onboarding-samples.test.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
/**
|
||||
* Onboarding samples — detection and categorisation contract (#354).
|
||||
*
|
||||
* `create_sample_import_folders` (Rust) writes two synthetic statements into the
|
||||
* import folder so a new user has something to import on the first run. The
|
||||
* command embeds the templates in `src/__fixtures__/samples/` with
|
||||
* `include_str!`; this file reads the SAME files and proves what the wizard will
|
||||
* do with them once the user clicks "Import":
|
||||
*
|
||||
* 1. detection recognises each file as the bank it imitates, with the
|
||||
* configuration the templates were written for, and reads every row;
|
||||
* 2. the full parse — the production `mapRow`, with the detected config —
|
||||
* yields no row in error and the signed totals the templates were balanced
|
||||
* to;
|
||||
* 3. the 180 seed keywords of a fresh profile (`consolidated_schema.sql`)
|
||||
* categorise the agreed share of each file, and leave the lines the
|
||||
* onboarding wants the user to classify BY HAND uncategorised — that step
|
||||
* of the checklist ("one transaction classified manually") needs something
|
||||
* left to do.
|
||||
*
|
||||
* The counts are FROZEN. If a seed keyword changes and moves one of them, the
|
||||
* expectation here is what tells the author the onboarding sample changed too.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import Papa from "papaparse";
|
||||
import { detectImportFormat, preprocessQuotedCSV } from "../utils/csvAutoDetect";
|
||||
import { detectAmountSeparators, mapRow } from "../utils/importFormat";
|
||||
import type { ParsedRow } from "../shared/types";
|
||||
import {
|
||||
readSampleTemplate,
|
||||
renderSampleTemplate,
|
||||
type SampleTemplateName,
|
||||
} from "../__fixtures__/samples";
|
||||
|
||||
vi.mock("../services/db", () => {
|
||||
const getDb = vi.fn();
|
||||
return {
|
||||
getDb,
|
||||
withTransaction: vi.fn(async (fn: (db: unknown) => unknown) => fn(await getDb())),
|
||||
};
|
||||
});
|
||||
|
||||
import { getDb } from "../services/db";
|
||||
import { categorizeBatch } from "../services/categorizationService";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The two rendered files, as the command writes them for a click on 2026-09-25.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const M1 = "08/2026";
|
||||
const M2 = "07/2026";
|
||||
|
||||
function renderedSample(name: SampleTemplateName): string {
|
||||
return renderSampleTemplate(readSampleTemplate(name), M1, M2);
|
||||
}
|
||||
|
||||
/** Detection outcome of a sample, or a failure the test can name. */
|
||||
function detectSample(name: SampleTemplateName) {
|
||||
const outcome = detectImportFormat(renderedSample(name));
|
||||
if (outcome.status !== "ok") {
|
||||
throw new Error(`${name}: detection returned ${outcome.status}`);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full pipeline, as the wizard runs it: raw text -> Papa rows -> data rows ->
|
||||
* column-level separator arbitration -> `mapRow` under the detected format.
|
||||
*/
|
||||
function parseSample(name: SampleTemplateName): ParsedRow[] {
|
||||
const rawContent = renderedSample(name);
|
||||
const { config } = detectSample(name);
|
||||
const format = { ...config, encoding: "utf-8" };
|
||||
|
||||
const data = Papa.parse(preprocessQuotedCSV(rawContent), {
|
||||
delimiter: config.delimiter,
|
||||
skipEmptyLines: true,
|
||||
}).data as string[][];
|
||||
|
||||
const startIdx = config.skipLines + (config.hasHeader ? 1 : 0);
|
||||
const dataRows: string[][] = [];
|
||||
for (let i = startIdx; i < data.length; i++) {
|
||||
const raw = data[i];
|
||||
if (raw.length <= 1 && raw[0]?.trim() === "") continue;
|
||||
dataRows.push(raw);
|
||||
}
|
||||
const decimalSeparators = detectAmountSeparators(dataRows, format);
|
||||
return dataRows.map((raw, rowIndex) =>
|
||||
mapRow(raw, format, { rowIndex, decimalSeparators })
|
||||
);
|
||||
}
|
||||
|
||||
/** Sum of the signed amounts, rounded to the cent to absorb float noise. */
|
||||
function sumOf(rows: ParsedRow[]): number {
|
||||
const total = rows.reduce((acc, r) => acc + (r.parsed?.amount ?? 0), 0);
|
||||
return Math.round(total * 100) / 100;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1 + 2 — detection and parse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAMPLES: {
|
||||
name: SampleTemplateName;
|
||||
bank: string;
|
||||
total: number;
|
||||
}[] = [
|
||||
{ name: "desjardins", bank: "desjardins", total: 740.0 },
|
||||
{ name: "tangerine", bank: "tangerine", total: -368.46 },
|
||||
];
|
||||
|
||||
describe("onboarding samples — detection (#354)", () => {
|
||||
it("renders every token: no `{{` survives in the files the user opens", () => {
|
||||
for (const { name } of SAMPLES) {
|
||||
const rendered = renderedSample(name);
|
||||
expect(rendered, name).not.toContain("{{");
|
||||
expect(rendered, name).toContain(M1);
|
||||
expect(rendered, name).toContain(M2);
|
||||
expect(rendered.trimEnd().split("\n"), name).toHaveLength(41);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(SAMPLES)(
|
||||
"$name: recognised as $bank, signed single amount, 40/40 rows read",
|
||||
({ name, bank }) => {
|
||||
const outcome = detectSample(name);
|
||||
expect(outcome.status).toBe("ok");
|
||||
expect(outcome.bank).toBe(bank);
|
||||
expect(outcome.config.signConvention).toBe("negative_expense");
|
||||
expect(outcome.config.amountMode).toBe("single");
|
||||
expect(outcome.config.dateFormat).toBe("DD/MM/YYYY");
|
||||
expect(outcome.score.totalRows).toBe(40);
|
||||
expect(outcome.score.readRows).toBe(40);
|
||||
expect(outcome.score.ratio).toBe(1);
|
||||
expect(outcome.score.confident).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(SAMPLES)(
|
||||
"$name: the full parse has no row in error and sums to $total",
|
||||
({ name, total }) => {
|
||||
const rows = parseSample(name);
|
||||
expect(rows).toHaveLength(40);
|
||||
expect(rows.filter((r) => r.error !== undefined)).toEqual([]);
|
||||
expect(rows.every((r) => r.parsed !== null && r.parsed !== undefined)).toBe(
|
||||
true
|
||||
);
|
||||
expect(sumOf(rows)).toBe(total);
|
||||
}
|
||||
);
|
||||
|
||||
it("desjardins: every date is in 08/2026 or 07/2026 after rendering", () => {
|
||||
const rows = parseSample("desjardins");
|
||||
const months = new Set(rows.map((r) => r.parsed!.date.slice(0, 7)));
|
||||
expect([...months].sort()).toEqual(["2026-07", "2026-08"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3 — categorisation by the seed keywords of a fresh profile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The 180 keywords `consolidated_schema.sql` seeds into a new profile, read
|
||||
* from the SQL itself so this test follows the seed rather than a copy of it.
|
||||
* Every INSERT has the same three-value shape; a line that does not match the
|
||||
* pattern would be a seed change this regex has to learn about, so the count is
|
||||
* asserted.
|
||||
*/
|
||||
function seedKeywords() {
|
||||
const sql = readFileSync(
|
||||
resolve(
|
||||
import.meta.dirname,
|
||||
"../../src-tauri/src/database/consolidated_schema.sql"
|
||||
),
|
||||
"utf-8"
|
||||
);
|
||||
const pattern =
|
||||
/INSERT INTO keywords \(keyword, category_id, priority\) VALUES \('((?:[^']|'')*)', (\d+), (\d+)\);/g;
|
||||
return [...sql.matchAll(pattern)].map((m, i) => ({
|
||||
id: i + 1,
|
||||
keyword: m[1].replace(/''/g, "'"),
|
||||
category_id: Number(m[2]),
|
||||
supplier_id: null,
|
||||
priority: Number(m[3]),
|
||||
is_active: 1,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Lines the onboarding wants left for the user to classify by hand. */
|
||||
const OFF_SEED: Record<SampleTemplateName, string[]> = {
|
||||
desjardins: [
|
||||
"GARDERIE LES PETITS PAS",
|
||||
"PAIEMENT VISA TANGERINE",
|
||||
"VIREMENT INTERAC RECU",
|
||||
"CLINIQUE DENTAIRE ST-LAMBERT",
|
||||
"RETRAITE QUEBEC ALLOC FAM",
|
||||
],
|
||||
tangerine: ["DOLLARAMA", "PAIEMENT - MERCI"],
|
||||
};
|
||||
|
||||
const CATEGORISED: Record<SampleTemplateName, number> = {
|
||||
desjardins: 33,
|
||||
tangerine: 36,
|
||||
};
|
||||
|
||||
describe("onboarding samples — seed categorisation (#354)", () => {
|
||||
const select = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
select.mockReset();
|
||||
vi.mocked(getDb).mockResolvedValue({ select, execute: vi.fn() } as never);
|
||||
});
|
||||
|
||||
it("reads the 180 seed keywords from the consolidated schema", () => {
|
||||
const keywords = seedKeywords();
|
||||
expect(keywords).toHaveLength(180);
|
||||
expect(keywords[0]).toMatchObject({ keyword: "METRO", category_id: 1111 });
|
||||
});
|
||||
|
||||
it.each(Object.keys(CATEGORISED) as SampleTemplateName[])(
|
||||
"%s: the seed categorises the frozen share and leaves the hand-classified lines alone",
|
||||
async (name) => {
|
||||
const keywords = seedKeywords();
|
||||
// `categorizeBatch` asks for active keywords by descending priority; the
|
||||
// seed is all priority 0, so the SQL order is the file order.
|
||||
select.mockResolvedValue(
|
||||
[...keywords].sort((a, b) => b.priority - a.priority)
|
||||
);
|
||||
|
||||
const rows = parseSample(name);
|
||||
const descriptions = rows.map((r) => r.parsed!.description);
|
||||
const results = await categorizeBatch(descriptions);
|
||||
|
||||
expect(select).toHaveBeenCalledWith(
|
||||
"SELECT * FROM keywords WHERE is_active = 1 ORDER BY priority DESC"
|
||||
);
|
||||
expect(results).toHaveLength(40);
|
||||
|
||||
const categorised = results.filter((r) => r.category_id !== null).length;
|
||||
expect(categorised).toBe(CATEGORISED[name]);
|
||||
expect(categorised / results.length).toBeGreaterThanOrEqual(0.8);
|
||||
|
||||
// The off-seed lines are present in the file AND left uncategorised.
|
||||
for (const description of OFF_SEED[name]) {
|
||||
const indexes = descriptions
|
||||
.map((d, i) => (d === description ? i : -1))
|
||||
.filter((i) => i >= 0);
|
||||
expect(indexes.length, description).toBeGreaterThan(0);
|
||||
for (const i of indexes) {
|
||||
expect(results[i].category_id, description).toBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
// And they are the ONLY uncategorised lines: nothing else slipped.
|
||||
const uncategorised = descriptions.filter(
|
||||
(_, i) => results[i].category_id === null
|
||||
);
|
||||
expect(new Set(uncategorised)).toEqual(new Set(OFF_SEED[name]));
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
@ -3,6 +3,7 @@ import { Pencil, RefreshCw } from "lucide-react";
|
|||
import type { Adjustment, Category } from "../../shared/types";
|
||||
import type { AdjustmentEntryWithCategory } from "../../services/adjustmentService";
|
||||
import type { AdjustmentFormData, EntryFormData } from "../../hooks/useAdjustments";
|
||||
import { confirmAction } from "../../services/confirmService";
|
||||
import AdjustmentForm from "./AdjustmentForm";
|
||||
|
||||
interface Props {
|
||||
|
|
@ -34,9 +35,9 @@ export default function AdjustmentDetailPanel({
|
|||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleDelete = () => {
|
||||
const handleDelete = async () => {
|
||||
if (!selectedAdjustment) return;
|
||||
if (!confirm(t("adjustments.deleteConfirm"))) return;
|
||||
if (!(await confirmAction(t("adjustments.deleteConfirm")))) return;
|
||||
onDelete(selectedAdjustment.id);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -27,19 +27,8 @@ vi.mock("../../services/userPreferenceService", () => ({
|
|||
setPreference: vi.fn(),
|
||||
}));
|
||||
|
||||
// react-i18next: return the key as-is for tests
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: vi.fn(() => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) => {
|
||||
// Include interpolation values in the returned string for assertions
|
||||
if (opts) {
|
||||
return `${key}(${JSON.stringify(opts)})`;
|
||||
}
|
||||
return key;
|
||||
},
|
||||
i18n: { language: "fr" },
|
||||
})),
|
||||
}));
|
||||
// react-i18next: the shared marked `t` (#389), so a text that skipped `t` fails
|
||||
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||
|
||||
// lucide-react: return simple stubs
|
||||
vi.mock("lucide-react", () => ({
|
||||
|
|
@ -58,7 +47,8 @@ import {
|
|||
getPreference,
|
||||
setPreference,
|
||||
} from "../../services/userPreferenceService";
|
||||
import {
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import PriceFetchControl, {
|
||||
__resetBestEffortDismissForTests,
|
||||
} from "./PriceFetchControl";
|
||||
|
||||
|
|
@ -363,3 +353,33 @@ describe("PriceFetchControl — fetchPrice invocation args", () => {
|
|||
expect(mockSetPreference).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: what the control displays (#389)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("PriceFetchControl — displayed texts", () => {
|
||||
beforeEach(() => {
|
||||
__resetBestEffortDismissForTests();
|
||||
vi.resetAllMocks();
|
||||
setPremium(true);
|
||||
});
|
||||
|
||||
it("names the fetch button and the stock best-effort notice through t", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<PriceFetchControl
|
||||
symbol="AAPL"
|
||||
date="2026-04-25"
|
||||
categoryKind="priced"
|
||||
assetType="stock"
|
||||
onPriceFetched={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('aria-label="t(balance.priceFetching.button)"');
|
||||
expect(html).toContain(">t(balance.priceFetching.button)<");
|
||||
expect(html).toContain(">t(balance.priceFetching.bestEffortNotice)<");
|
||||
expect(html).toContain(">t(balance.priceFetching.bestEffortBadge)<");
|
||||
expect(html).not.toContain(">best-effort<");
|
||||
expect(html).toContain('aria-label="t(common.close)"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ export default function PriceFetchControl({
|
|||
{/* Best-effort badge (stock only) */}
|
||||
{assetType === "stock" && (
|
||||
<span className="ml-0.5 text-[9px] uppercase tracking-wide px-1 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||
best-effort
|
||||
{t("balance.priceFetching.bestEffortBadge")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
|
|
|||
217
src/components/budget/BudgetTable.render.test.tsx
Normal file
217
src/components/budget/BudgetTable.render.test.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
// BudgetTable and the getting-started card's target (#400). No jsdom:
|
||||
// react-dom/server renders in vitest's node environment. The collapse hook is
|
||||
// replaced by the real pure helpers over a state the test sets (the real hook
|
||||
// hydrates in an effect, which a static render never runs), and the
|
||||
// subtotal-position preference is read from a stubbed localStorage. What is
|
||||
// pinned is where the one highlight lands: "Expand all" or this month's cell
|
||||
// of the first editable row shown.
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { BudgetYearRow } from "../../shared/types";
|
||||
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
||||
|
||||
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||
|
||||
const harness = vi.hoisted(() => ({
|
||||
/** Keys of the open groups (`p:<category id>`): the grid opens collapsed. */
|
||||
expanded: new Set<string>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useCollapsibleGroups", async () => {
|
||||
const { collapsibleKeys, isCollapsedFor, visibleRows } = await import("../../utils/collapsibleRows");
|
||||
return {
|
||||
useCollapsibleGroups: <T,>(_key: string | null, acc: CollapseAccessors<T>) => {
|
||||
const isCollapsed = (row: T) => isCollapsedFor(harness.expanded, acc.keyOf(row), false);
|
||||
return {
|
||||
visible: (rows: T[]) => visibleRows(rows, acc, isCollapsed),
|
||||
isCollapsed,
|
||||
toggle: () => undefined,
|
||||
expandAll: () => undefined,
|
||||
collapseAll: () => undefined,
|
||||
allExpanded: (rows: T[]) => {
|
||||
const keys = collapsibleKeys(rows, acc);
|
||||
return keys.length > 0 && keys.every((key) => harness.expanded.has(key));
|
||||
},
|
||||
groupCount: (rows: T[]) => collapsibleKeys(rows, acc).length,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import BudgetTable, { BUDGET_CELL_TARGET, BUDGET_EXPAND_ALL_TARGET } from "./BudgetTable";
|
||||
|
||||
const leaf = (
|
||||
category_id: number,
|
||||
category_name: string,
|
||||
category_type: BudgetYearRow["category_type"],
|
||||
parent_id: number | null = null,
|
||||
): BudgetYearRow => ({
|
||||
category_id,
|
||||
category_name,
|
||||
category_color: "#9ca3af",
|
||||
category_type,
|
||||
parent_id,
|
||||
is_parent: false,
|
||||
depth: parent_id === null ? 0 : 1,
|
||||
months: Array(12).fill(0) as number[],
|
||||
annual: 0,
|
||||
previousYearTotal: 0,
|
||||
});
|
||||
const group = (
|
||||
category_id: number,
|
||||
category_name: string,
|
||||
category_type: BudgetYearRow["category_type"],
|
||||
): BudgetYearRow => ({ ...leaf(category_id, category_name, category_type), is_parent: true });
|
||||
|
||||
/** Two groups, in the order `buildBudgetYearRows` emits them: income first. */
|
||||
const GROUPED: BudgetYearRow[] = [
|
||||
group(1, "Revenus", "income"),
|
||||
leaf(2, "Salaire", "income", 1),
|
||||
group(10, "Logement", "expense"),
|
||||
leaf(11, "Loyer", "expense", 10),
|
||||
leaf(12, "Électricité", "expense", 10),
|
||||
];
|
||||
/** A category without sub-category stays on screen while the groups are collapsed. */
|
||||
const WITH_STANDALONE: BudgetYearRow[] = [...GROUPED, leaf(30, "Divers", "expense")];
|
||||
/** No group at all. */
|
||||
const FLAT: BudgetYearRow[] = [leaf(30, "Divers", "expense"), leaf(31, "Cadeaux", "expense")];
|
||||
|
||||
const MARCH = 2;
|
||||
const DECEMBER = 11;
|
||||
|
||||
const render = (rows: BudgetYearRow[], onboardingTarget?: boolean) =>
|
||||
renderToStaticMarkup(
|
||||
<BudgetTable
|
||||
rows={rows}
|
||||
onUpdatePlanned={() => undefined}
|
||||
onSplitEvenly={() => undefined}
|
||||
onboardingTarget={onboardingTarget}
|
||||
/>,
|
||||
);
|
||||
|
||||
const HIGHLIGHT = 'data-onboarding-highlight="true"';
|
||||
const count = (html: string, needle: string) => html.split(needle).length - 1;
|
||||
|
||||
/** The one highlighted control: its target key and its inner markup. */
|
||||
function highlighted(html: string): { target: string; inner: string } {
|
||||
expect(count(html, HIGHLIGHT)).toBe(1);
|
||||
const match = html.match(/<button [^>]*data-onboarding-target="([^"]+)" data-onboarding-highlight="true"[^>]*>(.*?)<\/button>/);
|
||||
expect(match).not.toBeNull();
|
||||
return { target: match![1], inner: match![2] };
|
||||
}
|
||||
|
||||
/** The markup of the row named `name`, from its `<tr` to its `</tr>`. */
|
||||
function rowOf(html: string, name: string): string {
|
||||
const at = html.indexOf(`>${name}<`);
|
||||
expect(at).toBeGreaterThan(-1);
|
||||
return html.slice(html.lastIndexOf("<tr", at), html.indexOf("</tr>", at));
|
||||
}
|
||||
|
||||
/** The month (0-11) of the highlighted cell in a row, -1 for none: name, last year and annual come first. */
|
||||
function ringedMonth(row: string): number {
|
||||
const cell = row
|
||||
.split("<td")
|
||||
.slice(1)
|
||||
.findIndex((td) => td.includes(HIGHLIGHT));
|
||||
return cell === -1 ? -1 : cell - 3;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
harness.expanded = new Set();
|
||||
vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined });
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date(2026, MARCH, 15));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("BudgetTable — the getting-started card's target (#400)", () => {
|
||||
it("groups collapsed: 'Expand all' is the one target, no cell", () => {
|
||||
const html = render(GROUPED, true);
|
||||
const target = highlighted(html);
|
||||
expect(target.target).toBe(BUDGET_EXPAND_ALL_TARGET);
|
||||
expect(target.inner).toContain("t(reports.collapse.expandAll)");
|
||||
expect(html).not.toContain(BUDGET_CELL_TARGET);
|
||||
});
|
||||
|
||||
it("a group open: this month's cell of its first editable row is the one target", () => {
|
||||
harness.expanded = new Set(["p:10"]);
|
||||
const html = render(GROUPED, true);
|
||||
expect(highlighted(html).target).toBe(BUDGET_CELL_TARGET);
|
||||
expect(ringedMonth(rowOf(html, "Loyer"))).toBe(MARCH);
|
||||
expect(ringedMonth(rowOf(html, "Électricité"))).toBe(-1);
|
||||
// The group's subtotal row has no button to point at.
|
||||
expect(ringedMonth(rowOf(html, "Logement"))).toBe(-1);
|
||||
expect(html).not.toContain(BUDGET_EXPAND_ALL_TARGET);
|
||||
});
|
||||
|
||||
it("every group open: the first editable row of the grid, income first", () => {
|
||||
harness.expanded = new Set(["p:1", "p:10"]);
|
||||
const html = render(GROUPED, true);
|
||||
expect(ringedMonth(rowOf(html, "Salaire"))).toBe(MARCH);
|
||||
expect(count(html, HIGHLIGHT)).toBe(1);
|
||||
});
|
||||
|
||||
it("a category without sub-category shown beside collapsed groups: its cell, 'Expand all' stays plain", () => {
|
||||
const html = render(WITH_STANDALONE, true);
|
||||
expect(highlighted(html).target).toBe(BUDGET_CELL_TARGET);
|
||||
expect(ringedMonth(rowOf(html, "Divers"))).toBe(MARCH);
|
||||
expect(html).toContain("t(reports.collapse.expandAll)");
|
||||
expect(html).not.toContain(BUDGET_EXPAND_ALL_TARGET);
|
||||
});
|
||||
|
||||
it("no groups: no 'Expand all' at all, the first row's cell", () => {
|
||||
const html = render(FLAT, true);
|
||||
expect(html).not.toContain("t(reports.collapse.expandAll)");
|
||||
expect(ringedMonth(rowOf(html, "Divers"))).toBe(MARCH);
|
||||
expect(count(html, HIGHLIGHT)).toBe(1);
|
||||
});
|
||||
|
||||
describe("in a timezone west of Greenwich", () => {
|
||||
// Under TZ=UTC (the CI runner), 23:00 on December 31 is December both in
|
||||
// local time and in UTC, so a month read in UTC would pass unnoticed. The
|
||||
// zone is pinned as in useImportWizard.test.ts (#356).
|
||||
const originalTz = process.env.TZ;
|
||||
beforeAll(() => {
|
||||
process.env.TZ = "America/Toronto";
|
||||
});
|
||||
afterAll(() => {
|
||||
if (originalTz === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = originalTz;
|
||||
});
|
||||
|
||||
it("the cell follows the clock's month", () => {
|
||||
vi.setSystemTime(new Date(2026, DECEMBER, 31, 23, 0));
|
||||
expect(ringedMonth(rowOf(render(FLAT, true), "Divers"))).toBe(DECEMBER);
|
||||
});
|
||||
});
|
||||
|
||||
it("subtotals at the bottom: still the first editable row shown", () => {
|
||||
vi.stubGlobal("localStorage", { getItem: () => "bottom", setItem: () => undefined });
|
||||
harness.expanded = new Set(["p:10"]);
|
||||
const html = render(GROUPED, true);
|
||||
expect(html).toContain("t(reports.subtotalsOnBottom)");
|
||||
expect(ringedMonth(rowOf(html, "Loyer"))).toBe(MARCH);
|
||||
expect(count(html, HIGHLIGHT)).toBe(1);
|
||||
});
|
||||
|
||||
it("without `onboardingTarget`, nothing is highlighted", () => {
|
||||
for (const expanded of [[], ["p:10"]]) {
|
||||
harness.expanded = new Set(expanded);
|
||||
for (const onboardingTarget of [false, undefined]) {
|
||||
const html = render(GROUPED, onboardingTarget);
|
||||
expect(count(html, HIGHLIGHT)).toBe(0);
|
||||
expect(html).not.toContain("data-onboarding-target");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("no row: the grid's 'no category' box, nothing highlighted", () => {
|
||||
const html = render([], true);
|
||||
expect(html).toContain("t(budget.noCategories)");
|
||||
expect(count(html, HIGHLIGHT)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import { useState, useRef, useEffect, Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, ArrowUpDown, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
||||
import type { BudgetYearRow } from "../../shared/types";
|
||||
import type { BudgetOnboardingTargetKind, BudgetYearRow } from "../../shared/types";
|
||||
import { reorderRows } from "../../utils/reorderRows";
|
||||
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
||||
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
|
||||
import { useCollapsibleGroups } from "../../hooks/useCollapsibleGroups";
|
||||
import { computeBudgetResults, sumLeavesForType, type BudgetTotals } from "./budgetTableResults";
|
||||
import { budgetOnboardingTarget } from "./budgetOnboardingTarget";
|
||||
|
||||
const fmt = new Intl.NumberFormat("en-CA", {
|
||||
style: "currency",
|
||||
|
|
@ -37,13 +39,35 @@ const BUDGET_COLLAPSE_ACCESSORS: CollapseAccessors<BudgetYearRow> = {
|
|||
|
||||
const BUDGET_EXPANDED_KEY = "budget-grid-expanded";
|
||||
|
||||
/** `data-onboarding-target` of the highlighted month cell. */
|
||||
export const BUDGET_CELL_TARGET = "budget-cell";
|
||||
/** `data-onboarding-target` of the highlighted "Expand all" button. */
|
||||
export const BUDGET_EXPAND_ALL_TARGET = "budget-expand-all";
|
||||
|
||||
interface BudgetTableProps {
|
||||
rows: BudgetYearRow[];
|
||||
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
|
||||
onSplitEvenly: (categoryId: number, annualAmount: number) => void;
|
||||
/**
|
||||
* Highlight the control the getting-started card points at (#400): this
|
||||
* month's cell of the first editable row shown, or "Expand all" while none
|
||||
* is shown (`budgetOnboardingTarget`). One target at a time.
|
||||
*/
|
||||
onboardingTarget?: boolean;
|
||||
/**
|
||||
* Told which control that is, at mount and whenever it changes: the
|
||||
* collapse state lives here, and the card's sentence depends on it.
|
||||
*/
|
||||
onOnboardingTargetChange?: (kind: BudgetOnboardingTargetKind | null) => void;
|
||||
}
|
||||
|
||||
export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: BudgetTableProps) {
|
||||
export default function BudgetTable({
|
||||
rows,
|
||||
onUpdatePlanned,
|
||||
onSplitEvenly,
|
||||
onboardingTarget,
|
||||
onOnboardingTargetChange,
|
||||
}: BudgetTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const [editingCell, setEditingCell] = useState<{ categoryId: number; monthIdx: number } | null>(null);
|
||||
const [editingAnnual, setEditingAnnual] = useState<{ categoryId: number } | null>(null);
|
||||
|
|
@ -177,6 +201,30 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
|
||||
const totalCols = 15; // category + prev year + annual + 12 months
|
||||
|
||||
// The rows each section shows, in the order it shows them: collapse-filtered
|
||||
// (issue #289), then subtotals moved to the chosen side.
|
||||
const shownByType: Record<string, BudgetYearRow[]> = {};
|
||||
for (const type of typeOrder) {
|
||||
shownByType[type] = reorderRows(groups.visible(grouped[type] ?? []), subtotalsOnTop);
|
||||
}
|
||||
const hasGroups = groups.groupCount(rows) > 0;
|
||||
const allExpanded = groups.allExpanded(rows);
|
||||
|
||||
// The control the getting-started card points at (#400, D12): this month's
|
||||
// cell of the first editable row shown, else "Expand all". Its kind goes up
|
||||
// to the page, which picks the card's sentence; the grid stays as the user
|
||||
// left it (the card never unfolds it).
|
||||
const target = budgetOnboardingTarget(
|
||||
typeOrder.flatMap((type) => shownByType[type]),
|
||||
hasGroups,
|
||||
new Date().getMonth(),
|
||||
);
|
||||
const targetKind = target?.kind ?? null;
|
||||
useEffect(() => {
|
||||
onOnboardingTargetChange?.(targetKind);
|
||||
}, [targetKind, onOnboardingTargetChange]);
|
||||
const targetCell = onboardingTarget && target?.kind === "cell" ? target : null;
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]">
|
||||
|
|
@ -321,6 +369,12 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
onClick={() => handleStartEdit(row.category_id, mIdx, val)}
|
||||
title={t("budget.clickToEdit")}
|
||||
className="w-full text-right hover:text-[var(--primary)] hover:bg-[var(--muted)]/40 transition-colors cursor-pointer text-xs rounded px-1 py-0.5"
|
||||
{...onboardingTargetAttributes(
|
||||
BUDGET_CELL_TARGET,
|
||||
targetCell !== null &&
|
||||
targetCell.categoryId === row.category_id &&
|
||||
targetCell.monthIdx === mIdx,
|
||||
)}
|
||||
>
|
||||
{formatSigned(val * sign)}
|
||||
</button>
|
||||
|
|
@ -352,7 +406,7 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
{t(typeLabelKeys[type])}
|
||||
</td>
|
||||
</tr>
|
||||
{reorderRows(groups.visible(group), subtotalsOnTop).map((row) => renderRow(row))}
|
||||
{shownByType[type].map((row) => renderRow(row))}
|
||||
<tr className="bg-[var(--muted)]/40 border-b border-[var(--border)]">
|
||||
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)]/40 z-10 text-sm font-semibold">
|
||||
{t(typeTotalKeys[type])}
|
||||
|
|
@ -395,9 +449,6 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
);
|
||||
};
|
||||
|
||||
const hasGroups = groups.groupCount(rows) > 0;
|
||||
const allExpanded = groups.allExpanded(rows);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
|
||||
<div className="flex justify-end items-center gap-1 px-3 py-2 border-b border-[var(--border)]">
|
||||
|
|
@ -406,6 +457,10 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
type="button"
|
||||
onClick={() => (allExpanded ? groups.collapseAll(rows) : groups.expandAll(rows))}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
{...onboardingTargetAttributes(
|
||||
BUDGET_EXPAND_ALL_TARGET,
|
||||
onboardingTarget && target?.kind === "expandAll",
|
||||
)}
|
||||
>
|
||||
{allExpanded ? <ChevronsDownUp size={13} /> : <ChevronsUpDown size={13} />}
|
||||
{allExpanded ? t("reports.collapse.collapseAll") : t("reports.collapse.expandAll")}
|
||||
|
|
|
|||
129
src/components/budget/BudgetTable.wiring.test.ts
Normal file
129
src/components/budget/BudgetTable.wiring.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// BudgetTable — how the grid tells the page its target (#400, #409).
|
||||
//
|
||||
// renderToStaticMarkup runs no effect, so the report would stay unguarded.
|
||||
// React's hooks come from the shared harness (src/test/hookHarness.ts): the
|
||||
// grid runs as a plain function, its state and refs survive from one call to
|
||||
// the next as between two renders of a mounted component, and `commit()` runs
|
||||
// the effects as React does, an effect only when one of its dependencies
|
||||
// changed since its last run. The collapse hook is replaced by the real pure
|
||||
// helpers over a state the test sets.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { BudgetYearRow } from "../../shared/types";
|
||||
import type { CollapseAccessors } from "../../utils/collapsibleRows";
|
||||
|
||||
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
|
||||
const ctx = vi.hoisted(() => ({
|
||||
/** Keys of the open groups (`p:<category id>`): the grid opens collapsed. */
|
||||
expanded: new Set<string>(),
|
||||
}));
|
||||
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||
vi.mock("../../hooks/useCollapsibleGroups", async () => {
|
||||
const { collapsibleKeys, isCollapsedFor, visibleRows } = await import("../../utils/collapsibleRows");
|
||||
return {
|
||||
useCollapsibleGroups: <T,>(_key: string | null, acc: CollapseAccessors<T>) => {
|
||||
const isCollapsed = (row: T) => isCollapsedFor(ctx.expanded, acc.keyOf(row), false);
|
||||
return {
|
||||
visible: (rows: T[]) => visibleRows(rows, acc, isCollapsed),
|
||||
isCollapsed,
|
||||
toggle: () => undefined,
|
||||
expandAll: () => undefined,
|
||||
collapseAll: () => undefined,
|
||||
allExpanded: (rows: T[]) => {
|
||||
const keys = collapsibleKeys(rows, acc);
|
||||
return keys.length > 0 && keys.every((key) => ctx.expanded.has(key));
|
||||
},
|
||||
groupCount: (rows: T[]) => collapsibleKeys(rows, acc).length,
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { hookHarness } from "../../test/hookHarness";
|
||||
import BudgetTable from "./BudgetTable";
|
||||
import type { BudgetOnboardingTargetKind } from "../../shared/types";
|
||||
|
||||
const leaf = (category_id: number, parent_id: number | null = null): BudgetYearRow => ({
|
||||
category_id,
|
||||
category_name: `Category ${category_id}`,
|
||||
category_color: "#9ca3af",
|
||||
category_type: "expense",
|
||||
parent_id,
|
||||
is_parent: false,
|
||||
depth: parent_id === null ? 0 : 1,
|
||||
months: Array(12).fill(0) as number[],
|
||||
annual: 0,
|
||||
previousYearTotal: 0,
|
||||
});
|
||||
const GROUPED: BudgetYearRow[] = [{ ...leaf(10), is_parent: true }, leaf(11, 10), leaf(12, 10)];
|
||||
|
||||
const harness = hookHarness();
|
||||
|
||||
/** One render of the mounted grid: its hooks are called in the same order each time. */
|
||||
function render(
|
||||
rows: BudgetYearRow[],
|
||||
onChange: (kind: BudgetOnboardingTargetKind | null) => void,
|
||||
onboardingTarget?: boolean,
|
||||
) {
|
||||
harness.render(() =>
|
||||
BudgetTable({
|
||||
rows,
|
||||
onUpdatePlanned: () => undefined,
|
||||
onSplitEvenly: () => undefined,
|
||||
onboardingTarget,
|
||||
onOnboardingTargetChange: onChange,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** After a render: run each effect whose dependencies changed since its last run, as React does. */
|
||||
const commit = () => harness.commit();
|
||||
|
||||
beforeEach(() => {
|
||||
harness.clear();
|
||||
ctx.expanded = new Set();
|
||||
vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("BudgetTable — the target told to the page (#400)", () => {
|
||||
it("tells it once mounted, then only when it changes", () => {
|
||||
const onChange = vi.fn();
|
||||
render(GROUPED, onChange, true);
|
||||
commit();
|
||||
expect(onChange.mock.calls).toEqual([["expandAll"]]);
|
||||
|
||||
// Another render with nothing changed: not told again.
|
||||
render(GROUPED, onChange, true);
|
||||
commit();
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The user opens the group: an editable row shows.
|
||||
ctx.expanded = new Set(["p:10"]);
|
||||
render(GROUPED, onChange, true);
|
||||
commit();
|
||||
render(GROUPED, onChange, true);
|
||||
commit();
|
||||
expect(onChange.mock.calls).toEqual([["expandAll"], ["cell"]]);
|
||||
|
||||
// The rows are gone: nothing to point at.
|
||||
render([], onChange, true);
|
||||
commit();
|
||||
expect(onChange.mock.calls).toEqual([["expandAll"], ["cell"], [null]]);
|
||||
});
|
||||
|
||||
it("tells it before any highlight is asked: the page needs it for the card's sentence", () => {
|
||||
const onChange = vi.fn();
|
||||
render([leaf(30)], onChange);
|
||||
commit();
|
||||
expect(onChange.mock.calls).toEqual([["cell"]]);
|
||||
});
|
||||
|
||||
it("tells nothing during a render", () => {
|
||||
const onChange = vi.fn();
|
||||
render(GROUPED, onChange, true);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { BookTemplate, Save, Trash2 } from "lucide-react";
|
||||
import type { BudgetTemplate } from "../../shared/types";
|
||||
import { confirmAction } from "../../services/confirmService";
|
||||
|
||||
interface TemplateActionsProps {
|
||||
templates: BudgetTemplate[];
|
||||
|
|
@ -56,9 +57,9 @@ export default function TemplateActions({
|
|||
setShowSave(false);
|
||||
};
|
||||
|
||||
const handleDelete = (e: React.MouseEvent, templateId: number) => {
|
||||
const handleDelete = async (e: React.MouseEvent, templateId: number) => {
|
||||
e.stopPropagation();
|
||||
if (confirm(t("budget.deleteTemplateConfirm"))) {
|
||||
if (await confirmAction(t("budget.deleteTemplateConfirm"))) {
|
||||
onDelete(templateId);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
100
src/components/budget/budgetOnboardingTarget.test.ts
Normal file
100
src/components/budget/budgetOnboardingTarget.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { budgetOnboardingTarget } from "./budgetOnboardingTarget";
|
||||
import type { BudgetYearRow } from "../../shared/types";
|
||||
|
||||
/** A grid row; a leaf at the top level unless `overrides` says otherwise. */
|
||||
const row = (category_id: number, overrides: Partial<BudgetYearRow> = {}): BudgetYearRow => ({
|
||||
category_id,
|
||||
category_name: `Category ${category_id}`,
|
||||
category_color: "#9ca3af",
|
||||
category_type: "expense",
|
||||
parent_id: null,
|
||||
is_parent: false,
|
||||
depth: 0,
|
||||
months: Array(12).fill(0) as number[],
|
||||
annual: 0,
|
||||
previousYearTotal: 0,
|
||||
...overrides,
|
||||
});
|
||||
/** A group's subtotal row: read-only. */
|
||||
const group = (category_id: number, overrides: Partial<BudgetYearRow> = {}) =>
|
||||
row(category_id, { is_parent: true, ...overrides });
|
||||
/** A leaf under `parent_id`. */
|
||||
const child = (category_id: number, parent_id: number, depth = 1) =>
|
||||
row(category_id, { parent_id, depth });
|
||||
|
||||
const OCTOBER = 9;
|
||||
|
||||
describe("budgetOnboardingTarget (#400, D12)", () => {
|
||||
it("groups collapsed: 'Expand all', the editable rows being folded away", () => {
|
||||
// Collapsed, the grid shows its top-level subtotals only.
|
||||
expect(budgetOnboardingTarget([group(10), group(20)], true, OCTOBER)).toEqual({
|
||||
kind: "expandAll",
|
||||
});
|
||||
// A group open on a sub-group still folded: still no editable row shown.
|
||||
expect(
|
||||
budgetOnboardingTarget([group(10), group(11, { parent_id: 10, depth: 1 })], true, OCTOBER),
|
||||
).toEqual({ kind: "expandAll" });
|
||||
});
|
||||
|
||||
it("the first leaf shown: its cell in the given month, past the subtotal rows above it", () => {
|
||||
expect(budgetOnboardingTarget([group(10), child(13, 10), child(14, 10)], true, OCTOBER)).toEqual({
|
||||
kind: "cell",
|
||||
categoryId: 13,
|
||||
monthIdx: OCTOBER,
|
||||
});
|
||||
// A sub-group's subtotal before the leaves is skipped too.
|
||||
expect(
|
||||
budgetOnboardingTarget(
|
||||
[group(10), group(11, { parent_id: 10, depth: 1 }), child(15, 11, 2)],
|
||||
true,
|
||||
OCTOBER,
|
||||
),
|
||||
).toEqual({ kind: "cell", categoryId: 15, monthIdx: OCTOBER });
|
||||
});
|
||||
|
||||
it("subtotals at the bottom: the first leaf, not the subtotal under it", () => {
|
||||
expect(budgetOnboardingTarget([child(13, 10), child(14, 10), group(10)], true, OCTOBER)).toEqual({
|
||||
kind: "cell",
|
||||
categoryId: 13,
|
||||
monthIdx: OCTOBER,
|
||||
});
|
||||
});
|
||||
|
||||
it("a '(direct)' leaf is a leaf: its cell, though it shares its group's category id", () => {
|
||||
expect(
|
||||
budgetOnboardingTarget([group(10), row(10, { parent_id: 10, depth: 1 }), child(13, 10)], true, 0),
|
||||
).toEqual({ kind: "cell", categoryId: 10, monthIdx: 0 });
|
||||
});
|
||||
|
||||
it("a leaf shown while the groups are collapsed (a category without sub-category): already a cell", () => {
|
||||
expect(budgetOnboardingTarget([group(10), row(30), group(20)], true, OCTOBER)).toEqual({
|
||||
kind: "cell",
|
||||
categoryId: 30,
|
||||
monthIdx: OCTOBER,
|
||||
});
|
||||
});
|
||||
|
||||
it("without groups: the first row's cell", () => {
|
||||
expect(budgetOnboardingTarget([row(30), row(31)], false, OCTOBER)).toEqual({
|
||||
kind: "cell",
|
||||
categoryId: 30,
|
||||
monthIdx: OCTOBER,
|
||||
});
|
||||
});
|
||||
|
||||
it("no row: nothing to point at", () => {
|
||||
expect(budgetOnboardingTarget([], false, OCTOBER)).toBeNull();
|
||||
expect(budgetOnboardingTarget([], false, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the month it is given, January to December", () => {
|
||||
for (const monthIdx of [0, 5, 11]) {
|
||||
expect(budgetOnboardingTarget([row(30)], false, monthIdx)).toEqual({
|
||||
kind: "cell",
|
||||
categoryId: 30,
|
||||
monthIdx,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
33
src/components/budget/budgetOnboardingTarget.ts
Normal file
33
src/components/budget/budgetOnboardingTarget.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { BudgetYearRow } from "../../shared/types";
|
||||
|
||||
/**
|
||||
* The control of the budget grid that the getting-started card points at
|
||||
* (#400): the month cell of an editable row, or "Expand all".
|
||||
*/
|
||||
export type BudgetOnboardingTarget =
|
||||
| { kind: "cell"; categoryId: number; monthIdx: number }
|
||||
| { kind: "expandAll" };
|
||||
|
||||
/**
|
||||
* Which control of the budget grid the Budget step card points at (#400, D12).
|
||||
*
|
||||
* `visibleRows` are the rows the grid shows, in the order it shows them. A
|
||||
* subtotal row is read-only and a row folded under a collapsed group is not on
|
||||
* screen, so the target is:
|
||||
* - the `monthIdx` cell of the first leaf row shown;
|
||||
* - otherwise "Expand all" when the grid has groups: its editable rows are
|
||||
* folded away, and the card never unfolds them for the user;
|
||||
* - otherwise null: no row at all, nothing to point at.
|
||||
*
|
||||
* Pure (no React, no storage), like `budgetTableResults.ts`, so the rule is
|
||||
* tested without rendering the grid and its collapse hook.
|
||||
*/
|
||||
export function budgetOnboardingTarget(
|
||||
visibleRows: readonly BudgetYearRow[],
|
||||
hasGroups: boolean,
|
||||
monthIdx: number,
|
||||
): BudgetOnboardingTarget | null {
|
||||
const leaf = visibleRows.find((row) => !row.is_parent);
|
||||
if (leaf) return { kind: "cell", categoryId: leaf.category_id, monthIdx };
|
||||
return hasGroups ? { kind: "expandAll" } : null;
|
||||
}
|
||||
66
src/components/categories/AddKeywordDialog.render.test.tsx
Normal file
66
src/components/categories/AddKeywordDialog.render.test.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// AddKeywordDialog — the category field is a combobox placed outside the
|
||||
// scrolling block (#423). No jsdom: react-dom/server renders the initial
|
||||
// markup, before any effect runs, in vitest's node environment.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||
|
||||
vi.mock("./keywordCategory", () => ({
|
||||
loadKeywordCategories: vi.fn(() => new Promise(() => undefined)),
|
||||
reconcileInitialCategory: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("../../services/categorizationService", () => ({
|
||||
KEYWORD_MAX_LENGTH: 50,
|
||||
KEYWORD_MIN_LENGTH: 3,
|
||||
KEYWORD_PREVIEW_LIMIT: 50,
|
||||
applyKeywordWithReassignment: vi.fn(),
|
||||
previewKeywordMatches: vi.fn(() => new Promise(() => undefined)),
|
||||
validateKeyword: (raw: string) => {
|
||||
const value = raw.trim();
|
||||
return value.length >= 3 ? { ok: true, value } : { ok: false, reason: "tooShort" };
|
||||
},
|
||||
}));
|
||||
|
||||
import AddKeywordDialog from "./AddKeywordDialog";
|
||||
|
||||
const markup = (initialCategoryId: number | null = null) =>
|
||||
renderToStaticMarkup(
|
||||
<AddKeywordDialog
|
||||
initialKeyword="METRO"
|
||||
initialCategoryId={initialCategoryId}
|
||||
onClose={vi.fn()}
|
||||
onApplied={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe("AddKeywordDialog — category field (#423)", () => {
|
||||
it("renders the category as a combobox and no select", () => {
|
||||
const html = markup();
|
||||
expect(html.match(/<input [^>]*role="combobox"[^>]*>/g) ?? []).toHaveLength(1);
|
||||
expect(html).not.toContain("<select");
|
||||
});
|
||||
|
||||
it("starts empty, with the 'no category' placeholder, until the list is in", () => {
|
||||
const [input] = markup(7).match(/<input [^>]*role="combobox"[^>]*>/g) ?? [];
|
||||
expect(input).toContain('value=""');
|
||||
expect(input).toContain('placeholder="t(transactions.table.noCategory)"');
|
||||
});
|
||||
|
||||
it("keeps the apply button disabled before the list is in", () => {
|
||||
const html = markup(7);
|
||||
const [apply] = html.match(/<button [^>]*>t\(reports\.keyword\.applyAndRecategorize\)<\/button>/g) ?? [];
|
||||
expect(apply).toBeDefined();
|
||||
expect(apply).toContain("disabled");
|
||||
});
|
||||
|
||||
it("places the combobox before any scrolling block, so its list is not clipped", () => {
|
||||
const html = markup();
|
||||
const at = html.indexOf('role="combobox"');
|
||||
expect(at).toBeGreaterThan(0);
|
||||
expect(html.slice(0, at)).not.toContain("overflow-y-auto");
|
||||
// The preview block below still scrolls on its own.
|
||||
expect(html.slice(at)).toContain("overflow-y-auto");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { RecentTransaction } from "../../shared/types";
|
||||
import type { Category, RecentTransaction } from "../../shared/types";
|
||||
import {
|
||||
KEYWORD_MAX_LENGTH,
|
||||
KEYWORD_MIN_LENGTH,
|
||||
|
|
@ -9,12 +9,8 @@ import {
|
|||
previewKeywordMatches,
|
||||
validateKeyword,
|
||||
} from "../../services/categorizationService";
|
||||
import { getAllCategoriesWithCounts } from "../../services/categoryService";
|
||||
|
||||
interface CategoryOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||
import { loadKeywordCategories, reconcileInitialCategory } from "./keywordCategory";
|
||||
|
||||
export interface AddKeywordDialogProps {
|
||||
initialKeyword: string;
|
||||
|
|
@ -37,8 +33,11 @@ export default function AddKeywordDialog({
|
|||
}: AddKeywordDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [keyword, setKeyword] = useState(initialKeyword);
|
||||
const [categoryId, setCategoryId] = useState<number | null>(initialCategoryId);
|
||||
const [categories, setCategories] = useState<CategoryOption[]>([]);
|
||||
// Held at null until the list is in, which keeps the apply button disabled
|
||||
// meanwhile: the pre-filled category is only kept when it is among the
|
||||
// categories the dialog offers (`reconcileInitialCategory`).
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [checked, setChecked] = useState<Set<number>>(new Set());
|
||||
const [applyToHidden, setApplyToHidden] = useState(false);
|
||||
const [preview, setPreview] = useState<PreviewState>({ kind: "idle" });
|
||||
|
|
@ -50,10 +49,22 @@ export default function AddKeywordDialog({
|
|||
const validation = useMemo(() => validateKeyword(keyword), [keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
getAllCategoriesWithCounts()
|
||||
.then((rows) => setCategories(rows.map((r) => ({ id: r.id, name: r.name }))))
|
||||
.catch(() => setCategories([]));
|
||||
}, []);
|
||||
let cancelled = false;
|
||||
loadKeywordCategories()
|
||||
.then((rows) => {
|
||||
if (cancelled) return;
|
||||
setCategories(rows);
|
||||
setCategoryId(reconcileInitialCategory(initialCategoryId, rows));
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setCategories([]);
|
||||
setCategoryId(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [initialCategoryId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!validation.ok) {
|
||||
|
|
@ -135,7 +146,10 @@ export default function AddKeywordDialog({
|
|||
<h2 className="text-base font-semibold">{t("reports.keyword.dialogTitle")}</h2>
|
||||
</header>
|
||||
|
||||
<div className="px-5 py-4 flex flex-col gap-4 overflow-y-auto">
|
||||
{/* The two fields sit outside the scrolling block, so the open category
|
||||
list is drawn over the preview below instead of being cut by its
|
||||
scrollport. */}
|
||||
<div className="px-5 pt-4 flex flex-col gap-4">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
|
||||
{t("reports.keyword.dialogTitle")}
|
||||
|
|
@ -160,19 +174,16 @@ export default function AddKeywordDialog({
|
|||
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
|
||||
{t("reports.highlights.category")}
|
||||
</span>
|
||||
<select
|
||||
value={categoryId ?? ""}
|
||||
onChange={(e) => setCategoryId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<CategoryCombobox
|
||||
categories={categories}
|
||||
value={categoryId}
|
||||
onChange={setCategoryId}
|
||||
placeholder={t("transactions.table.noCategory")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="px-5 pb-4 pt-4 flex flex-col gap-4 overflow-y-auto min-h-0">
|
||||
|
||||
<section>
|
||||
<h3 className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide mb-2">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
// CategoryDetailPanel — the edited category's id reaches the parent options (#423).
|
||||
// No jsdom: react-dom/server renders in vitest's node environment.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||
|
||||
vi.mock("./parentOptions", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./parentOptions")>();
|
||||
return { ...actual, buildParentOptions: vi.fn(actual.buildParentOptions) };
|
||||
});
|
||||
|
||||
import CategoryDetailPanel from "./CategoryDetailPanel";
|
||||
import { buildParentOptions } from "./parentOptions";
|
||||
import type { CategoryFormData, CategoryTreeNode } from "../../shared/types";
|
||||
|
||||
const node = (id: number, parent_id: number | null, name: string): CategoryTreeNode => ({
|
||||
id,
|
||||
name,
|
||||
parent_id,
|
||||
color: null,
|
||||
icon: null,
|
||||
type: "expense",
|
||||
is_active: true,
|
||||
is_inputable: true,
|
||||
sort_order: id,
|
||||
keyword_count: 0,
|
||||
i18n_key: null,
|
||||
children: [],
|
||||
});
|
||||
|
||||
const HOUSING = node(1, null, "Logement");
|
||||
const RENT = node(10, 1, "Loyer");
|
||||
const CATEGORIES = [HOUSING, RENT];
|
||||
|
||||
const formOf = (c: CategoryTreeNode): CategoryFormData => ({
|
||||
name: c.name,
|
||||
type: c.type,
|
||||
color: "#4A90A4",
|
||||
parent_id: c.parent_id,
|
||||
is_inputable: c.is_inputable,
|
||||
sort_order: c.sort_order,
|
||||
});
|
||||
|
||||
const markup = (selected: CategoryTreeNode | null, editing: CategoryFormData | null, isCreating: boolean) =>
|
||||
renderToStaticMarkup(
|
||||
<CategoryDetailPanel
|
||||
categories={CATEGORIES}
|
||||
selectedCategory={selected}
|
||||
keywords={[]}
|
||||
editingCategory={editing}
|
||||
isCreating={isCreating}
|
||||
isSaving={false}
|
||||
onStartEditing={vi.fn()}
|
||||
onCancelEditing={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onDelete={vi.fn(async () => ({ blocked: false, count: 0 }))}
|
||||
onAddKeyword={vi.fn()}
|
||||
onUpdateKeyword={vi.fn()}
|
||||
onRemoveKeyword={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe("CategoryDetailPanel — editingId wiring (#423)", () => {
|
||||
it("passes the selected category's id when editing", () => {
|
||||
vi.mocked(buildParentOptions).mockClear();
|
||||
const html = markup(RENT, formOf(RENT), false);
|
||||
expect(html).toContain('role="combobox"');
|
||||
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: 10, currentParentId: 1 });
|
||||
});
|
||||
|
||||
it("passes no id when creating", () => {
|
||||
vi.mocked(buildParentOptions).mockClear();
|
||||
const html = markup(null, formOf(node(0, null, "")), true);
|
||||
expect(html).toContain('role="combobox"');
|
||||
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: null, currentParentId: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("CategoryDetailPanel — displayed texts (#389)", () => {
|
||||
it("titles the edit view and the create view through t", () => {
|
||||
expect(markup(RENT, formOf(RENT), false)).toContain(">t(categories.editCategory)<");
|
||||
expect(markup(null, formOf(node(0, null, "")), true)).toContain(">t(categories.addCategory)<");
|
||||
});
|
||||
|
||||
it("asks for a selection through t when nothing is selected", () => {
|
||||
expect(markup(null, null, false)).toContain(">t(categories.selectCategory)<");
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@ import { useState } from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Pencil } from "lucide-react";
|
||||
import type { CategoryTreeNode, CategoryFormData, Keyword } from "../../shared/types";
|
||||
import { confirmAction } from "../../services/confirmService";
|
||||
import CategoryForm from "./CategoryForm";
|
||||
import KeywordList from "./KeywordList";
|
||||
|
||||
|
|
@ -41,7 +42,7 @@ export default function CategoryDetailPanel({
|
|||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedCategory) return;
|
||||
if (!confirm(t("categories.deleteConfirm"))) return;
|
||||
if (!(await confirmAction(t("categories.deleteConfirm")))) return;
|
||||
setDeleteError(null);
|
||||
const result = await onDelete(selectedCategory.id);
|
||||
if (result.blocked) {
|
||||
|
|
@ -90,6 +91,7 @@ export default function CategoryDetailPanel({
|
|||
<CategoryForm
|
||||
initialData={editingCategory}
|
||||
categories={categories}
|
||||
editingId={selectedCategory.id}
|
||||
isCreating={false}
|
||||
isSaving={isSaving}
|
||||
onSave={onSave}
|
||||
|
|
|
|||
108
src/components/categories/CategoryForm.render.test.tsx
Normal file
108
src/components/categories/CategoryForm.render.test.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// CategoryForm — the parent field is a combobox, not a select (#423). No jsdom:
|
||||
// react-dom/server renders in vitest's node environment; the markup says what
|
||||
// the closed combobox shows as its value.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||
|
||||
// Pass-through spy on the pure module, to pin what the form hands it.
|
||||
vi.mock("./parentOptions", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./parentOptions")>();
|
||||
return { ...actual, buildParentOptions: vi.fn(actual.buildParentOptions) };
|
||||
});
|
||||
|
||||
import CategoryForm from "./CategoryForm";
|
||||
import { buildParentOptions } from "./parentOptions";
|
||||
import type { CategoryFormData, CategoryTreeNode } from "../../shared/types";
|
||||
|
||||
const node = (
|
||||
id: number,
|
||||
parent_id: number | null,
|
||||
name: string,
|
||||
i18n_key: string | null = null,
|
||||
): CategoryTreeNode => ({
|
||||
id,
|
||||
name,
|
||||
parent_id,
|
||||
color: null,
|
||||
icon: null,
|
||||
type: "expense",
|
||||
is_active: true,
|
||||
is_inputable: true,
|
||||
sort_order: id,
|
||||
keyword_count: 0,
|
||||
i18n_key,
|
||||
children: [],
|
||||
});
|
||||
|
||||
const HOUSING = node(1, null, "Logement", "categoriesSeed.housing");
|
||||
const RENT = node(10, 1, "Loyer");
|
||||
const CATEGORIES = [HOUSING, RENT];
|
||||
|
||||
const form = (parent_id: number | null): CategoryFormData => ({
|
||||
name: "Taxes",
|
||||
type: "expense",
|
||||
color: "#4A90A4",
|
||||
parent_id,
|
||||
is_inputable: true,
|
||||
sort_order: 0,
|
||||
});
|
||||
|
||||
const markup = (data: CategoryFormData, editingId?: number) =>
|
||||
renderToStaticMarkup(
|
||||
<CategoryForm
|
||||
initialData={data}
|
||||
categories={CATEGORIES}
|
||||
editingId={editingId}
|
||||
isCreating={editingId === undefined}
|
||||
isSaving={false}
|
||||
onSave={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
/** The parent combobox input (the only `role="combobox"` in the form). */
|
||||
const comboboxInput = (html: string) => {
|
||||
const inputs = html.match(/<input [^>]*role="combobox"[^>]*>/g) ?? [];
|
||||
expect(inputs).toHaveLength(1);
|
||||
return inputs[0];
|
||||
};
|
||||
|
||||
describe("CategoryForm — parent field (#423)", () => {
|
||||
it("renders the parent as a combobox labelled by the field name, with no parent select left", () => {
|
||||
const html = markup(form(null));
|
||||
const input = comboboxInput(html);
|
||||
expect(input).toContain('aria-label="t(categories.parent)"');
|
||||
// Only the type select remains.
|
||||
expect(html.match(/<select/g) ?? []).toHaveLength(1);
|
||||
expect(html).not.toContain("categories.noParent</option>");
|
||||
});
|
||||
|
||||
it("shows the 'no parent' option as the value when creating without a parent", () => {
|
||||
expect(comboboxInput(markup(form(null)))).toContain('value="t(categories.noParent)"');
|
||||
});
|
||||
|
||||
it("shows the parent's translated name as the value when editing a category with a parent", () => {
|
||||
// Seed rows translate through their i18n_key with the name as default value.
|
||||
expect(comboboxInput(markup(form(1), 10))).toContain(
|
||||
'value="t(categoriesSeed.housing[defaultValue=Logement])"',
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a user-created parent by its name", () => {
|
||||
expect(comboboxInput(markup(form(10), 42))).toContain('value="Loyer"');
|
||||
});
|
||||
|
||||
it("hands the edited id and the stored parent to buildParentOptions", () => {
|
||||
vi.mocked(buildParentOptions).mockClear();
|
||||
markup(form(1), 10);
|
||||
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: 10, currentParentId: 1 });
|
||||
});
|
||||
|
||||
it("hands no edited id when creating", () => {
|
||||
vi.mocked(buildParentOptions).mockClear();
|
||||
markup(form(null));
|
||||
expect(buildParentOptions).toHaveBeenCalledWith(CATEGORIES, { editingId: null, currentParentId: null });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import type { CategoryFormData, CategoryTreeNode } from "../../shared/types";
|
||||
import CategoryCombobox from "../shared/CategoryCombobox";
|
||||
import { buildParentOptions } from "./parentOptions";
|
||||
|
||||
const PRESET_COLORS = [
|
||||
"#4A90A4",
|
||||
|
|
@ -18,6 +20,8 @@ const PRESET_COLORS = [
|
|||
interface Props {
|
||||
initialData: CategoryFormData;
|
||||
categories: CategoryTreeNode[];
|
||||
/** Id of the category being edited; omitted when creating. */
|
||||
editingId?: number | null;
|
||||
isCreating: boolean;
|
||||
isSaving: boolean;
|
||||
onSave: (data: CategoryFormData) => void;
|
||||
|
|
@ -28,6 +32,7 @@ interface Props {
|
|||
export default function CategoryForm({
|
||||
initialData,
|
||||
categories,
|
||||
editingId = null,
|
||||
isCreating,
|
||||
isSaving,
|
||||
onSave,
|
||||
|
|
@ -41,36 +46,16 @@ export default function CategoryForm({
|
|||
setForm(initialData);
|
||||
}, [initialData]);
|
||||
|
||||
// Allow level 0 and level 1 categories as parents (but not level 2, which would create a 4th level)
|
||||
// Also build indentation info
|
||||
const parentOptions: Array<CategoryTreeNode & { indent: number }> = [];
|
||||
for (const cat of categories) {
|
||||
if (cat.parent_id === null) {
|
||||
// Level 0 — always allowed as parent
|
||||
parentOptions.push({ ...cat, indent: 0 });
|
||||
}
|
||||
}
|
||||
for (const cat of categories) {
|
||||
if (cat.parent_id !== null) {
|
||||
// Check if this category's parent is a root (making this level 1)
|
||||
const parent = categories.find((c) => c.id === cat.parent_id);
|
||||
if (parent && parent.parent_id === null) {
|
||||
// Level 1 — allowed as parent (would create level 3 children)
|
||||
parentOptions.push({ ...cat, indent: 1 });
|
||||
}
|
||||
// Level 2 categories are NOT shown (would create level 4)
|
||||
}
|
||||
}
|
||||
// Sort to keep hierarchy order: group by root parent sort_order
|
||||
parentOptions.sort((a, b) => {
|
||||
const rootA = a.indent === 0 ? a : categories.find((c) => c.id === a.parent_id);
|
||||
const rootB = b.indent === 0 ? b : categories.find((c) => c.id === b.parent_id);
|
||||
const orderA = rootA?.sort_order ?? 999;
|
||||
const orderB = rootB?.sort_order ?? 999;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
if (a.indent !== b.indent) return a.indent - b.indent;
|
||||
return a.sort_order - b.sort_order;
|
||||
});
|
||||
// Roots and level-1 categories, minus the edited category and its descent;
|
||||
// the combobox orders them hierarchically. See `buildParentOptions`.
|
||||
const parentOptions = useMemo(
|
||||
() => buildParentOptions(categories, { editingId, currentParentId: form.parent_id }),
|
||||
[categories, editingId, form.parent_id],
|
||||
);
|
||||
|
||||
// "No parent" sits first in the list and is how the field goes back to null:
|
||||
// the combobox itself never emits null.
|
||||
const noParentExtra = useMemo(() => [{ value: "", label: t("categories.noParent") }], [t]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -130,20 +115,15 @@ export default function CategoryForm({
|
|||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t("categories.parent")}</label>
|
||||
<select
|
||||
value={form.parent_id ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, parent_id: e.target.value ? Number(e.target.value) : null })
|
||||
}
|
||||
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||
>
|
||||
<option value="">{t("categories.noParent")}</option>
|
||||
{parentOptions.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.indent > 0 ? "\u00A0\u00A0\u00A0\u00A0" : ""}{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<CategoryCombobox
|
||||
categories={parentOptions}
|
||||
value={form.parent_id}
|
||||
onChange={(id) => setForm({ ...form, parent_id: id })}
|
||||
extraOptions={noParentExtra}
|
||||
activeExtra={form.parent_id === null ? "" : null}
|
||||
onExtraSelect={() => setForm({ ...form, parent_id: null })}
|
||||
ariaLabel={t("categories.parent")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
50
src/components/categories/keywordCategory.test.ts
Normal file
50
src/components/categories/keywordCategory.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// The category list of the "add a keyword" dialog and its pre-filled value (#423, D2).
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// `vi.mock` is hoisted above the imports: the mocks must come from `vi.hoisted`.
|
||||
const { getAllCategories, getAllCategoriesWithCounts } = vi.hoisted(() => ({
|
||||
getAllCategories: vi.fn(),
|
||||
getAllCategoriesWithCounts: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../services/transactionService", () => ({ getAllCategories }));
|
||||
vi.mock("../../services/categoryService", () => ({ getAllCategoriesWithCounts }));
|
||||
|
||||
import { loadKeywordCategories, reconcileInitialCategory } from "./keywordCategory";
|
||||
|
||||
const cats = [
|
||||
{ id: 1, name: "Logement", sort_order: 1 },
|
||||
{ id: 7, name: "Épicerie", sort_order: 2 },
|
||||
];
|
||||
|
||||
describe("loadKeywordCategories", () => {
|
||||
beforeEach(() => {
|
||||
getAllCategories.mockReset();
|
||||
getAllCategoriesWithCounts.mockReset();
|
||||
});
|
||||
|
||||
it("reads the Transactions list (active and inputable), not the full category list", async () => {
|
||||
getAllCategories.mockResolvedValue(cats);
|
||||
await expect(loadKeywordCategories()).resolves.toBe(cats);
|
||||
expect(getAllCategories).toHaveBeenCalledTimes(1);
|
||||
expect(getAllCategoriesWithCounts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileInitialCategory", () => {
|
||||
it("keeps the pre-filled category when it is in the list", () => {
|
||||
expect(reconcileInitialCategory(7, cats)).toBe(7);
|
||||
});
|
||||
|
||||
it("drops a pre-filled category that is not in the list", () => {
|
||||
expect(reconcileInitialCategory(42, cats)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null without a pre-filled category", () => {
|
||||
expect(reconcileInitialCategory(null, cats)).toBeNull();
|
||||
expect(reconcileInitialCategory(undefined, cats)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the list is empty", () => {
|
||||
expect(reconcileInitialCategory(7, [])).toBeNull();
|
||||
});
|
||||
});
|
||||
25
src/components/categories/keywordCategory.ts
Normal file
25
src/components/categories/keywordCategory.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { Category } from "../../shared/types";
|
||||
import { getAllCategories } from "../../services/transactionService";
|
||||
import type { CategoryLike } from "../shared/CategoryCombobox";
|
||||
|
||||
/**
|
||||
* Categories offered by the "add a keyword" dialog: the same list as the
|
||||
* category cell of the Transactions page (active and inputable), so a keyword
|
||||
* can only target a category the user can also pick by hand (#423, D2).
|
||||
*/
|
||||
export function loadKeywordCategories(): Promise<Category[]> {
|
||||
return getAllCategories();
|
||||
}
|
||||
|
||||
/**
|
||||
* The category pre-filled by the right-click menu is kept only when it is in
|
||||
* the list; a category the user cannot see (hidden from input, inactive) is
|
||||
* dropped so the dialog never applies an id that is not shown.
|
||||
*/
|
||||
export function reconcileInitialCategory(
|
||||
initialId: number | null | undefined,
|
||||
categories: CategoryLike[],
|
||||
): number | null {
|
||||
if (initialId == null) return null;
|
||||
return categories.some((c) => c.id === initialId) ? initialId : null;
|
||||
}
|
||||
106
src/components/categories/parentOptions.test.ts
Normal file
106
src/components/categories/parentOptions.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// buildParentOptions — which categories the category form offers as a parent (#423).
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildParentOptions } from "./parentOptions";
|
||||
import type { CategoryTreeNode } from "../../shared/types";
|
||||
|
||||
const node = (id: number, parent_id: number | null, name = `c${id}`): CategoryTreeNode => ({
|
||||
id,
|
||||
name,
|
||||
parent_id,
|
||||
color: null,
|
||||
icon: null,
|
||||
type: "expense",
|
||||
is_active: true,
|
||||
is_inputable: true,
|
||||
sort_order: id,
|
||||
keyword_count: 0,
|
||||
i18n_key: null,
|
||||
children: [],
|
||||
});
|
||||
|
||||
// Two roots. Root 1 has a child (10) with its own child (100), and a leaf child (11).
|
||||
// Root 2 has a leaf child (20).
|
||||
const TREE = [node(1, null), node(10, 1), node(100, 10), node(11, 1), node(2, null), node(20, 2)];
|
||||
const ids = (list: CategoryTreeNode[]) => list.map((c) => c.id).sort((a, b) => a - b);
|
||||
|
||||
describe("buildParentOptions — creating a category", () => {
|
||||
it("offers roots and level-1 categories, never level 2", () => {
|
||||
expect(ids(buildParentOptions(TREE))).toEqual([1, 2, 10, 11, 20]);
|
||||
});
|
||||
|
||||
it("offers roots alone when there is nothing deeper", () => {
|
||||
expect(ids(buildParentOptions([node(1, null), node(2, null)]))).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("treats a category whose parent is missing as a root", () => {
|
||||
// 10's parent (1) is not in the list: 10 is a root, 100 a level-1 child.
|
||||
expect(ids(buildParentOptions([node(10, 1), node(100, 10), node(1000, 100)]))).toEqual([10, 100]);
|
||||
});
|
||||
|
||||
it("returns [] for an empty list", () => {
|
||||
expect(buildParentOptions([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildParentOptions — editing a category", () => {
|
||||
it("leaves out the edited category itself", () => {
|
||||
expect(ids(buildParentOptions(TREE, { editingId: 11 }))).toEqual([1, 2, 10, 20]);
|
||||
});
|
||||
|
||||
it("offers nothing when the edited category has grandchildren", () => {
|
||||
// Root 1 carries 10 -> 100: under any parent, 100 would sit at level 3.
|
||||
// Probe of the review of PR #428: `[1, 10->1, 100->10, 2]`, editing 1.
|
||||
expect(ids(buildParentOptions([node(1, null), node(10, 1), node(100, 10), node(2, null)], { editingId: 1 }))).toEqual([]);
|
||||
expect(ids(buildParentOptions(TREE, { editingId: 1 }))).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves out the whole descent of the edited category", () => {
|
||||
// Editing 10 (child 100): a level-1 category with a child; 100 stays out.
|
||||
expect(ids(buildParentOptions(TREE, { editingId: 10 }))).not.toContain(100);
|
||||
// Editing root 2 (child 20): only roots remain, so 20 stays out.
|
||||
expect(ids(buildParentOptions(TREE, { editingId: 2 }))).not.toContain(20);
|
||||
});
|
||||
|
||||
it("keeps only roots when the edited category has children but no grandchildren", () => {
|
||||
// 10 has a child (100): under a level-1 parent its child would sit at level 3.
|
||||
expect(ids(buildParentOptions(TREE, { editingId: 10 }))).toEqual([1, 2]);
|
||||
expect(ids(buildParentOptions(TREE, { editingId: 2 }))).toEqual([1]);
|
||||
});
|
||||
|
||||
it("keeps level-1 categories when the edited category is a leaf", () => {
|
||||
expect(ids(buildParentOptions(TREE, { editingId: 20 }))).toEqual([1, 2, 10, 11]);
|
||||
});
|
||||
|
||||
it("always keeps the parent currently set on the form, even one the rules would drop", () => {
|
||||
// Legacy data: 1000 sits under 100 (level 3). Editing 1000, its parent 100 is
|
||||
// level 2 and would be dropped; it stays so the field shows what is stored.
|
||||
const deep = [...TREE, node(1000, 100)];
|
||||
expect(ids(buildParentOptions(deep, { editingId: 1000, currentParentId: 100 }))).toEqual([
|
||||
1, 2, 10, 11, 20, 100,
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not re-add the edited category as its own current parent", () => {
|
||||
expect(ids(buildParentOptions(TREE, { editingId: 11, currentParentId: 11 }))).toEqual([1, 2, 10, 20]);
|
||||
});
|
||||
|
||||
it("ignores a current parent that is not in the list", () => {
|
||||
expect(ids(buildParentOptions(TREE, { editingId: 11, currentParentId: 999 }))).toEqual([1, 2, 10, 20]);
|
||||
});
|
||||
|
||||
it("terminates on a cycle and never offers the edited category", () => {
|
||||
// Which members of the cycle are offered is not specified (#393 repairs
|
||||
// cycles); the walk must end, and 3 must stay out.
|
||||
const cyclic = [node(1, 2), node(2, 1), node(3, null)];
|
||||
const offered = ids(buildParentOptions(cyclic, { editingId: 3 }));
|
||||
expect(offered).not.toContain(3);
|
||||
expect(offered.every((id) => id === 1 || id === 2)).toBe(true);
|
||||
});
|
||||
|
||||
it("terminates when the edited category is itself a member of a cycle", () => {
|
||||
// 1 <-> 2: the height walk under 1 meets 1 again and stops (height 1, not
|
||||
// unbounded). Only roots remain, so the root outside the cycle is offered.
|
||||
const cyclic = [node(1, 2), node(2, 1), node(3, null)];
|
||||
expect(ids(buildParentOptions(cyclic, { editingId: 1 }))).toEqual([3]);
|
||||
});
|
||||
});
|
||||
109
src/components/categories/parentOptions.ts
Normal file
109
src/components/categories/parentOptions.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import type { CategoryTreeNode } from "../../shared/types";
|
||||
|
||||
/** Deepest level a category may sit at: root (0), child (1), grandchild (2). */
|
||||
const MAX_PARENT_DEPTH = 1;
|
||||
|
||||
export interface ParentOptionsContext {
|
||||
/** Id of the category being edited; absent or null when creating. */
|
||||
editingId?: number | null;
|
||||
/**
|
||||
* Parent currently stored on the form. Always kept in the options when it
|
||||
* exists, so a legacy or inconsistent parent never leaves the field blank
|
||||
* while `parent_id` is set.
|
||||
*/
|
||||
currentParentId?: number | null;
|
||||
}
|
||||
|
||||
function depthsOf(categories: CategoryTreeNode[]): Map<number, number> {
|
||||
const byId = new Map<number, CategoryTreeNode>();
|
||||
for (const c of categories) byId.set(c.id, c);
|
||||
const depths = new Map<number, number>();
|
||||
const depthOf = (id: number, seen: Set<number>): number => {
|
||||
const known = depths.get(id);
|
||||
if (known !== undefined) return known;
|
||||
if (seen.has(id)) return 0; // cycle: treat as a root rather than loop
|
||||
seen.add(id);
|
||||
const cat = byId.get(id);
|
||||
const parentId = cat?.parent_id ?? null;
|
||||
// A missing parent (orphan) counts as a root, like the tree and the combobox do.
|
||||
const d = parentId === null || !byId.has(parentId) ? 0 : depthOf(parentId, seen) + 1;
|
||||
depths.set(id, d);
|
||||
return d;
|
||||
};
|
||||
for (const c of categories) depthOf(c.id, new Set());
|
||||
return depths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Height of the sub-tree under `rootId`: 0 for a leaf, 1 with children, 2 with
|
||||
* grandchildren. Walks `parent_id` links; a cycle is not walked twice, and the
|
||||
* walk can never take more steps than there are categories.
|
||||
*/
|
||||
function subtreeHeight(categories: CategoryTreeNode[], rootId: number): number {
|
||||
const childrenOf = new Map<number, number[]>();
|
||||
for (const c of categories) {
|
||||
if (c.parent_id === null) continue;
|
||||
const bucket = childrenOf.get(c.parent_id) ?? [];
|
||||
bucket.push(c.id);
|
||||
childrenOf.set(c.parent_id, bucket);
|
||||
}
|
||||
let height = 0;
|
||||
const seen = new Set<number>([rootId]);
|
||||
let frontier = [rootId];
|
||||
while (frontier.length > 0 && height < categories.length) {
|
||||
const next: number[] = [];
|
||||
for (const id of frontier) {
|
||||
for (const child of childrenOf.get(id) ?? []) {
|
||||
if (seen.has(child)) continue;
|
||||
seen.add(child);
|
||||
next.push(child);
|
||||
}
|
||||
}
|
||||
if (next.length === 0) break;
|
||||
height += 1;
|
||||
frontier = next;
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
/**
|
||||
* Categories that may be chosen as the parent in the category form.
|
||||
*
|
||||
* - Roots and level-1 categories only: a level-2 parent would create a fourth
|
||||
* level, which nothing else in the app knows how to display.
|
||||
* - When editing, the edited category is left out, and the allowed parent depth
|
||||
* shrinks by the height of its sub-tree: with children, only roots remain
|
||||
* (the children would otherwise land at level 3); with grandchildren, no
|
||||
* parent at all (they would land at level 4). This alone also leaves out its
|
||||
* whole descent, which would create a cycle that hides the branch everywhere
|
||||
* (#393): a descendant sits at least one level below the edited category,
|
||||
* deeper than any depth still allowed.
|
||||
* - The parent currently set on the form is always kept when it exists.
|
||||
*
|
||||
* This list is the only guard: `updateCategory` writes any `parent_id` it is
|
||||
* given, without a depth or cycle check (#393 will add one). The form therefore
|
||||
* never offers a new parent that the rules above reject.
|
||||
*
|
||||
* Ordering is left to the caller (`CategoryCombobox` sorts hierarchically).
|
||||
*/
|
||||
export function buildParentOptions(
|
||||
categories: CategoryTreeNode[],
|
||||
ctx: ParentOptionsContext = {},
|
||||
): CategoryTreeNode[] {
|
||||
const editingId = ctx.editingId ?? null;
|
||||
const currentParentId = ctx.currentParentId ?? null;
|
||||
const depths = depthsOf(categories);
|
||||
|
||||
const height = editingId !== null ? subtreeHeight(categories, editingId) : 0;
|
||||
// Negative when the edited category has grandchildren: only the current parent survives.
|
||||
const maxDepth = MAX_PARENT_DEPTH - height;
|
||||
|
||||
return categories.filter((c) => {
|
||||
if (c.id === editingId) return false;
|
||||
// Kept even when the depth rule would drop it: the field must show what is
|
||||
// stored. Keeping it changes nothing on save (it is already the parent);
|
||||
// repairing such data is the job of #393.
|
||||
if (c.id === currentParentId) return true;
|
||||
return (depths.get(c.id) ?? 0) <= maxDepth;
|
||||
});
|
||||
}
|
||||
72
src/components/dashboard/DashboardEmptyState.render.test.tsx
Normal file
72
src/components/dashboard/DashboardEmptyState.render.test.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// DashboardEmptyState — the welcome card of a profile without transactions
|
||||
// (#352). #397 leaves it as it was: the card keeps the detailed list, which
|
||||
// now also takes the "Resume" handler. No jsdom: react-dom/server for the
|
||||
// markup, a function call for the wiring.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||
|
||||
import DashboardEmptyState from "./DashboardEmptyState";
|
||||
import OnboardingChecklist from "./OnboardingChecklist";
|
||||
import { computeChecklist } from "../../utils/onboardingChecklist";
|
||||
|
||||
const checklist = computeChecklist(
|
||||
{ sources: 0, imports: 0, manualCategorized: 0, budgetEntries: 0, transactions: 0 },
|
||||
{ importFolderSet: false, budgetAllowed: true, visitStepsInitialized: true },
|
||||
);
|
||||
|
||||
function cardProps(hidden = false) {
|
||||
return {
|
||||
checklist,
|
||||
budgetReady: true,
|
||||
visitStepsInitialized: true,
|
||||
hidden,
|
||||
onHide: vi.fn(),
|
||||
onResume: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
const markup = (hidden = false) =>
|
||||
renderToStaticMarkup(
|
||||
<MemoryRouter>
|
||||
<DashboardEmptyState {...cardProps(hidden)} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
describe("DashboardEmptyState (#352, #397)", () => {
|
||||
it("shows the welcome card, its action towards Import and the detailed list", () => {
|
||||
const html = markup();
|
||||
expect(html).toContain("t(dashboard.empty.title)");
|
||||
expect(html).toContain("t(dashboard.empty.description)");
|
||||
expect(html).toMatch(/<a [^>]*href="\/import"[^>]*>t\(dashboard\.empty\.cta\)/);
|
||||
expect(html).toContain('aria-label="t(onboarding.checklist.title)"');
|
||||
expect(html).toContain("t(onboarding.checklist.counter[done=1,total=8])");
|
||||
});
|
||||
|
||||
it("keeps the card and drops the list once the path is hidden", () => {
|
||||
const html = markup(true);
|
||||
expect(html).toContain("t(dashboard.empty.title)");
|
||||
expect(html).not.toContain("onboarding.checklist.title");
|
||||
});
|
||||
|
||||
it("hands its list the path's handlers", () => {
|
||||
const props = cardProps();
|
||||
const card = DashboardEmptyState(props) as ReactElement<{ children?: ReactNode }>;
|
||||
const list = card.props.children as ReactElement<Record<string, unknown>>;
|
||||
expect(list.type).toBe(OnboardingChecklist);
|
||||
expect(list.props.items).toBe(checklist.items);
|
||||
expect(list.props.budgetReady).toBe(true);
|
||||
expect(list.props.visitStepsInitialized).toBe(true);
|
||||
const waiting = DashboardEmptyState({ ...props, visitStepsInitialized: false }) as ReactElement<{
|
||||
children?: ReactNode;
|
||||
}>;
|
||||
expect(
|
||||
(waiting.props.children as ReactElement<Record<string, unknown>>).props.visitStepsInitialized,
|
||||
).toBe(false);
|
||||
expect(list.props.onHide).toBe(props.onHide);
|
||||
expect(list.props.onResume).toBe(props.onResume);
|
||||
});
|
||||
});
|
||||
56
src/components/dashboard/DashboardEmptyState.tsx
Normal file
56
src/components/dashboard/DashboardEmptyState.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { LayoutDashboard } from "lucide-react";
|
||||
import EmptyState from "../shared/EmptyState";
|
||||
import OnboardingChecklist from "./OnboardingChecklist";
|
||||
import type { Checklist } from "../../utils/onboardingChecklist";
|
||||
import type { ChecklistItemKey } from "../../shared/types";
|
||||
|
||||
interface DashboardEmptyStateProps {
|
||||
checklist: Checklist;
|
||||
budgetReady: boolean;
|
||||
/** The visit steps are initialized (#425): their rows stay "pending" until then. */
|
||||
visitStepsInitialized: boolean;
|
||||
/** The user dismissed the checklist: the welcome card stays, the list goes. */
|
||||
hidden: boolean;
|
||||
onHide: () => void;
|
||||
/** "Resume" on a skipped row of the list (#397). */
|
||||
onResume: (key: ChecklistItemKey) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard of a profile without any transaction (#352): a welcome card with
|
||||
* the detailed getting-started checklist and one primary action towards the
|
||||
* import page, in place of widgets showing 0 $. The getting-started banner
|
||||
* stays off this page (D11): the card already holds the list.
|
||||
*/
|
||||
export default function DashboardEmptyState({
|
||||
checklist,
|
||||
budgetReady,
|
||||
visitStepsInitialized,
|
||||
hidden,
|
||||
onHide,
|
||||
onResume,
|
||||
}: DashboardEmptyStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<LayoutDashboard size={40} />}
|
||||
title={t("dashboard.empty.title")}
|
||||
description={t("dashboard.empty.description")}
|
||||
primaryAction={{ label: t("dashboard.empty.cta"), to: "/import" }}
|
||||
>
|
||||
{!hidden && (
|
||||
<OnboardingChecklist
|
||||
items={checklist.items}
|
||||
done={checklist.done}
|
||||
total={checklist.total}
|
||||
budgetReady={budgetReady}
|
||||
visitStepsInitialized={visitStepsInitialized}
|
||||
onHide={onHide}
|
||||
onResume={onResume}
|
||||
/>
|
||||
)}
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
318
src/components/dashboard/OnboardingChecklist.test.tsx
Normal file
318
src/components/dashboard/OnboardingChecklist.test.tsx
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
// OnboardingChecklist — unit tests (#352, #397).
|
||||
//
|
||||
// FilterPanel.test.tsx pattern: no jsdom, `react-i18next` and `lucide-react`
|
||||
// mocked, the component called as a function and its element tree walked.
|
||||
// `t` returns the key, with the interpolation values appended, so the asserts
|
||||
// read the i18n keys and never a hard-coded string.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
CheckCircle2: () => null,
|
||||
Circle: () => null,
|
||||
Lock: () => null,
|
||||
SkipForward: () => null,
|
||||
}));
|
||||
|
||||
import { CheckCircle2, Circle, Lock, SkipForward } from "lucide-react";
|
||||
import OnboardingChecklist, { type OnboardingChecklistProps } from "./OnboardingChecklist";
|
||||
import { computeChecklist } from "../../utils/onboardingChecklist";
|
||||
import type { ChecklistItemKey, OnboardingCounters } from "../../shared/types";
|
||||
|
||||
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
|
||||
|
||||
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
|
||||
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
|
||||
if (!isValidElement(node)) return [];
|
||||
const el = node as AnyElement;
|
||||
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
|
||||
}
|
||||
|
||||
function texts(node: ReactNode): string[] {
|
||||
if (Array.isArray(node)) return node.flatMap(texts);
|
||||
if (typeof node === "string") return [node];
|
||||
if (!isValidElement(node)) return [];
|
||||
return texts((node as AnyElement).props.children);
|
||||
}
|
||||
|
||||
// Leaves of the walk: react-router's Link and the mocked icons keep their type.
|
||||
const LEAVES = new Set<unknown>([Link, CheckCircle2, Circle, Lock, SkipForward]);
|
||||
|
||||
/** Resolves the inner `RowIcon` component so the icon elements are visible. */
|
||||
function expand(node: ReactNode): ReactNode {
|
||||
if (Array.isArray(node)) return node.map(expand);
|
||||
if (!isValidElement(node)) return node;
|
||||
const el = node as AnyElement;
|
||||
if (typeof el.type === "function" && !LEAVES.has(el.type)) {
|
||||
const render = el.type as (props: Record<string, unknown>) => ReactNode;
|
||||
return expand(render(el.props));
|
||||
}
|
||||
const children = el.props.children;
|
||||
return { ...el, props: { ...el.props, children: expand(children) } } as ReactNode;
|
||||
}
|
||||
|
||||
const ZERO: OnboardingCounters = {
|
||||
sources: 0,
|
||||
imports: 0,
|
||||
manualCategorized: 0,
|
||||
budgetEntries: 0,
|
||||
transactions: 0,
|
||||
};
|
||||
|
||||
function render(
|
||||
overrides: Partial<OnboardingChecklistProps> = {},
|
||||
counters = ZERO,
|
||||
budgetAllowed = true,
|
||||
skipped: ChecklistItemKey[] = [],
|
||||
visitStepsInitialized = true,
|
||||
) {
|
||||
const checklist = computeChecklist(counters, {
|
||||
importFolderSet: false,
|
||||
budgetAllowed,
|
||||
skipped,
|
||||
visitStepsInitialized,
|
||||
});
|
||||
const onHide = vi.fn();
|
||||
const onResume = vi.fn();
|
||||
const props: OnboardingChecklistProps = {
|
||||
items: checklist.items,
|
||||
done: checklist.done,
|
||||
total: checklist.total,
|
||||
budgetReady: true,
|
||||
visitStepsInitialized,
|
||||
onHide,
|
||||
onResume,
|
||||
...overrides,
|
||||
};
|
||||
return { element: expand(OnboardingChecklist(props)), onHide, onResume, checklist };
|
||||
}
|
||||
|
||||
function rowOf(element: ReactNode, key: string): AnyElement {
|
||||
const row = find(element, (e) => e.type === "li" && e.key === key)[0];
|
||||
expect(row, `row ${key}`).toBeDefined();
|
||||
return row;
|
||||
}
|
||||
|
||||
/** The visually hidden texts of a row: what assistive technologies read after the label. */
|
||||
function stateTextsOf(row: AnyElement): string[] {
|
||||
return find(row, (e) => e.type === "span" && e.props.className === "sr-only").flatMap((e) =>
|
||||
texts(e.props.children).map((s) => s.trim()).filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
describe("OnboardingChecklist", () => {
|
||||
it("renders the title, the counter 1 / 8 and the hide button", () => {
|
||||
const { element, onHide } = render();
|
||||
const text = texts(element);
|
||||
expect(text).toContain("t(onboarding.checklist.title)");
|
||||
expect((element as AnyElement).props["aria-label"]).toBe("t(onboarding.checklist.title)");
|
||||
expect(text).toContain("t(onboarding.checklist.counter[done=1,total=8])");
|
||||
const [hide] = find(element, (e) => e.type === "button");
|
||||
expect(hide.props.type).toBe("button");
|
||||
expect(texts(hide.props.children)).toContain("t(onboarding.checklist.hide)");
|
||||
(hide.props.onClick as () => void)();
|
||||
expect(onHide).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders eight rows, in order, with their i18n labels (#425)", () => {
|
||||
const { element } = render();
|
||||
const rows = find(element, (e) => e.type === "li");
|
||||
const order = [
|
||||
"profile",
|
||||
"importFolder",
|
||||
"source",
|
||||
"import",
|
||||
"categories",
|
||||
"manualCategory",
|
||||
"budget",
|
||||
"reports",
|
||||
];
|
||||
expect(rows.map((r) => r.key)).toEqual(order);
|
||||
const text = texts(element);
|
||||
for (const key of order) {
|
||||
expect(text).toContain(`t(onboarding.checklist.items.${key})`);
|
||||
}
|
||||
});
|
||||
|
||||
it("links every undone row to its `to`, and never a done row", () => {
|
||||
const { element, checklist } = render();
|
||||
const links = find(element, (e) => e.type === Link);
|
||||
const undone = checklist.items.filter((i) => !i.done);
|
||||
expect(links.map((l) => l.props.to)).toEqual(undone.map((i) => i.to));
|
||||
const profile = rowOf(element, "profile");
|
||||
expect(find(profile, (e) => e.type === Link)).toHaveLength(0);
|
||||
expect(find(profile, (e) => e.type === CheckCircle2)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("gives the link of a row to do the step's navigation state, guided on the manual category", () => {
|
||||
const { element } = render();
|
||||
const [folder] = find(rowOf(element, "importFolder"), (e) => e.type === Link);
|
||||
expect(folder.props.state).toEqual({ onboarding: { focus: true } });
|
||||
const [manual] = find(rowOf(element, "manualCategory"), (e) => e.type === Link);
|
||||
expect(manual.props.to).toBe("/transactions");
|
||||
expect(manual.props.state).toEqual({ onboarding: { focus: true, guided: true } });
|
||||
});
|
||||
|
||||
it("marks a row done when its counter is reached", () => {
|
||||
const { element } = render({}, { ...ZERO, sources: 1 });
|
||||
const source = rowOf(element, "source");
|
||||
expect(find(source, (e) => e.type === CheckCircle2)).toHaveLength(1);
|
||||
expect(find(source, (e) => e.type === Link)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("in Free, the budget row shows a lock, the 'Base and up' label and links to the license card", () => {
|
||||
const { element } = render({}, ZERO, false);
|
||||
const budget = rowOf(element, "budget");
|
||||
expect(find(budget, (e) => e.type === Lock)).toHaveLength(1);
|
||||
expect(texts(budget)).toContain("t(onboarding.checklist.lockedBudget)");
|
||||
const [link] = find(budget, (e) => e.type === Link);
|
||||
expect(link.props.to).toBe("/settings/users");
|
||||
// The license card carries no step card: nothing to focus there.
|
||||
expect(link.props.state).toBeUndefined();
|
||||
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=1,total=7])");
|
||||
});
|
||||
|
||||
it("while the entitlement is unknown, the budget row is neutral and the counter is absent", () => {
|
||||
const { element } = render({ budgetReady: false }, ZERO, false);
|
||||
const budget = rowOf(element, "budget");
|
||||
expect(find(budget, (e) => e.type === Lock)).toHaveLength(0);
|
||||
expect(find(budget, (e) => e.type === CheckCircle2)).toHaveLength(0);
|
||||
expect(find(budget, (e) => e.type === Circle)).toHaveLength(1);
|
||||
expect(find(budget, (e) => e.type === Link)).toHaveLength(0);
|
||||
expect(texts(budget)).not.toContain("t(onboarding.checklist.lockedBudget)");
|
||||
expect(texts(element).some((s) => s.startsWith("t(onboarding.checklist.counter"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OnboardingChecklist — states read out (#384, #397)", () => {
|
||||
it("reads done, skipped and locked after the label, and nothing for a row still to do", () => {
|
||||
const { element } = render({}, ZERO, false, ["source"]);
|
||||
expect(stateTextsOf(rowOf(element, "profile"))).toEqual(["t(onboarding.state.done)"]);
|
||||
expect(stateTextsOf(rowOf(element, "source"))).toEqual(["t(onboarding.state.skipped)"]);
|
||||
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.locked)"]);
|
||||
expect(stateTextsOf(rowOf(element, "importFolder"))).toEqual([]);
|
||||
expect(stateTextsOf(rowOf(element, "import"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads the budget row as pending while the entitlement is unknown", () => {
|
||||
const { element } = render({ budgetReady: false }, ZERO, false);
|
||||
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.pending)"]);
|
||||
});
|
||||
|
||||
it("puts the state text right after the label, inside the same element", () => {
|
||||
const { element } = render({}, { ...ZERO, sources: 1 });
|
||||
const [labelled] = find(
|
||||
rowOf(element, "source"),
|
||||
(e) => e.type === "span" && texts(e.props.children).includes("t(onboarding.checklist.items.source)"),
|
||||
);
|
||||
expect(texts(labelled.props.children).map((s) => s.trim()).filter(Boolean)).toEqual([
|
||||
"t(onboarding.checklist.items.source)",
|
||||
"t(onboarding.state.done)",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OnboardingChecklist — skipped rows (#397)", () => {
|
||||
it("shows the skip icon, the 'Skipped' badge and 'Resume', and counts the row", () => {
|
||||
const { element } = render({}, ZERO, true, ["source"]);
|
||||
const source = rowOf(element, "source");
|
||||
expect(find(source, (e) => e.type === SkipForward)).toHaveLength(1);
|
||||
expect(find(source, (e) => e.type === Circle)).toHaveLength(0);
|
||||
const [badge] = find(
|
||||
source,
|
||||
(e) => e.type === "span" && texts(e.props.children).includes("t(onboarding.checklist.skippedBadge)"),
|
||||
);
|
||||
// The hidden "(skipped)" text already says it: the badge is not read twice.
|
||||
expect(badge.props["aria-hidden"]).toBe("true");
|
||||
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=2,total=8])");
|
||||
});
|
||||
|
||||
it("'Resume' takes the step back, then opens its page with the step's navigation state", () => {
|
||||
const { element, onResume } = render({}, ZERO, true, ["source", "manualCategory"]);
|
||||
const sourceLinks = find(rowOf(element, "source"), (e) => e.type === Link);
|
||||
// The label is no link: only "Resume" leads to the page, after taking the step back.
|
||||
expect(sourceLinks).toHaveLength(1);
|
||||
const [resume] = sourceLinks;
|
||||
expect(texts(resume.props.children)).toEqual(["t(onboarding.checklist.resume)"]);
|
||||
expect(resume.props.to).toBe("/import");
|
||||
expect(resume.props.state).toEqual({ onboarding: { focus: true } });
|
||||
(resume.props.onClick as () => void)();
|
||||
expect(onResume).toHaveBeenCalledTimes(1);
|
||||
expect(onResume).toHaveBeenCalledWith("source");
|
||||
|
||||
const [manual] = find(rowOf(element, "manualCategory"), (e) => e.type === Link);
|
||||
expect(manual.props.state).toEqual({ onboarding: { focus: true, guided: true } });
|
||||
(manual.props.onClick as () => void)();
|
||||
expect(onResume).toHaveBeenLastCalledWith("manualCategory");
|
||||
});
|
||||
|
||||
it("a row done and skipped shows as done, without 'Resume'", () => {
|
||||
const { element } = render({}, { ...ZERO, sources: 1 }, true, ["source"]);
|
||||
const source = rowOf(element, "source");
|
||||
expect(find(source, (e) => e.type === CheckCircle2)).toHaveLength(1);
|
||||
expect(find(source, (e) => e.type === SkipForward)).toHaveLength(0);
|
||||
expect(find(source, (e) => e.type === Link)).toHaveLength(0);
|
||||
expect(texts(source)).not.toContain("t(onboarding.checklist.resume)");
|
||||
});
|
||||
|
||||
it("a locked row stays locked even when skipped", () => {
|
||||
const { element } = render({}, ZERO, false, ["budget"]);
|
||||
const budget = rowOf(element, "budget");
|
||||
expect(find(budget, (e) => e.type === Lock)).toHaveLength(1);
|
||||
expect(find(budget, (e) => e.type === SkipForward)).toHaveLength(0);
|
||||
expect(texts(budget)).not.toContain("t(onboarding.checklist.resume)");
|
||||
const [link] = find(budget, (e) => e.type === Link);
|
||||
expect(link.props.to).toBe("/settings/users");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OnboardingChecklist — visit steps (#425)", () => {
|
||||
it("a visit row to do links to its page with the step's navigation state", () => {
|
||||
const { element } = render();
|
||||
const [categories] = find(rowOf(element, "categories"), (e) => e.type === Link);
|
||||
expect(categories.props.to).toBe("/categories");
|
||||
expect(categories.props.state).toEqual({ onboarding: { focus: true } });
|
||||
const [reports] = find(rowOf(element, "reports"), (e) => e.type === Link);
|
||||
expect(reports.props.to).toBe("/reports");
|
||||
});
|
||||
|
||||
it("before the initialization, both visit rows are pending, without the edition badge, and the counter waits", () => {
|
||||
const { element } = render({}, ZERO, false, [], false);
|
||||
for (const key of ["categories", "reports"]) {
|
||||
const row = rowOf(element, key);
|
||||
expect(find(row, (e) => e.type === Lock), key).toHaveLength(0);
|
||||
expect(find(row, (e) => e.type === Circle), key).toHaveLength(1);
|
||||
expect(find(row, (e) => e.type === Link), key).toHaveLength(0);
|
||||
expect(texts(row), key).not.toContain("t(onboarding.checklist.lockedBudget)");
|
||||
expect(stateTextsOf(row), key).toEqual(["t(onboarding.state.pending)"]);
|
||||
}
|
||||
// The Budget row keeps its own rule: entitlement known, Free → locked.
|
||||
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.locked)"]);
|
||||
expect(texts(element).some((s) => s.startsWith("t(onboarding.checklist.counter"))).toBe(false);
|
||||
});
|
||||
|
||||
it("the Budget row's pending state follows budgetReady alone", () => {
|
||||
const { element } = render({ budgetReady: false }, ZERO, true, [], true);
|
||||
expect(stateTextsOf(rowOf(element, "budget"))).toEqual(["t(onboarding.state.pending)"]);
|
||||
expect(stateTextsOf(rowOf(element, "categories"))).toEqual([]);
|
||||
const initializedOnly = render({ budgetReady: true }, ZERO, true, [], false).element;
|
||||
expect(stateTextsOf(rowOf(initializedOnly, "budget"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("an acknowledged visit row shows as done", () => {
|
||||
const checklist = computeChecklist(ZERO, {
|
||||
importFolderSet: false,
|
||||
budgetAllowed: true,
|
||||
visitStepsInitialized: true,
|
||||
acknowledged: ["reports"],
|
||||
});
|
||||
const { element } = render({ items: checklist.items, done: checklist.done, total: checklist.total });
|
||||
const reports = rowOf(element, "reports");
|
||||
expect(find(reports, (e) => e.type === CheckCircle2)).toHaveLength(1);
|
||||
expect(find(reports, (e) => e.type === Link)).toHaveLength(0);
|
||||
expect(texts(element)).toContain("t(onboarding.checklist.counter[done=2,total=8])");
|
||||
});
|
||||
});
|
||||
187
src/components/dashboard/OnboardingChecklist.tsx
Normal file
187
src/components/dashboard/OnboardingChecklist.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { CheckCircle2, Circle, Lock, SkipForward } from "lucide-react";
|
||||
import { stepNavState } from "../../utils/onboardingPath";
|
||||
import { VISIT_STEP_KEYS, type ChecklistItem, type ChecklistItemKey } from "../../shared/types";
|
||||
|
||||
export interface OnboardingChecklistProps {
|
||||
items: ChecklistItem[];
|
||||
/** Number of unlocked rows that are done or skipped. */
|
||||
done: number;
|
||||
/** Number of unlocked rows (8, or 7 when Budget is locked). */
|
||||
total: number;
|
||||
/**
|
||||
* False until the Budget entitlement is known. The budget row is then
|
||||
* neutral (neither done nor locked) and the counter is hidden, because
|
||||
* `total` depends on the lock.
|
||||
*/
|
||||
budgetReady: boolean;
|
||||
/**
|
||||
* False until the profile's visit steps are initialized (#425). Their two
|
||||
* rows are then neutral ("pending", never "locked", which would show the
|
||||
* Base edition badge) and the counter is hidden, `total` depending on them.
|
||||
*/
|
||||
visitStepsInitialized: boolean;
|
||||
onHide: () => void;
|
||||
/** "Resume" on a skipped row: the provider's `resume`, before its link opens the step's page. */
|
||||
onResume: (key: ChecklistItemKey) => void;
|
||||
}
|
||||
|
||||
type RowState = "done" | "skipped" | "todo" | "locked" | "pending";
|
||||
|
||||
/**
|
||||
* A locked row stays locked whatever else it is, and a row the data says is
|
||||
* done shows as done even if it was skipped too: "Resume" would bring back
|
||||
* nothing to do.
|
||||
*/
|
||||
function rowState(
|
||||
item: ChecklistItem,
|
||||
budgetReady: boolean,
|
||||
visitStepsInitialized: boolean,
|
||||
): RowState {
|
||||
if (item.key === "budget" && !budgetReady) return "pending";
|
||||
if (VISIT_STEP_KEYS.includes(item.key) && !visitStepsInitialized) return "pending";
|
||||
if (item.locked) return "locked";
|
||||
if (item.done) return "done";
|
||||
return item.skipped ? "skipped" : "todo";
|
||||
}
|
||||
|
||||
const ICON_CLASS: Record<RowState, string> = {
|
||||
done: "text-[var(--positive)]",
|
||||
skipped: "text-[var(--muted-foreground)]",
|
||||
todo: "text-[var(--muted-foreground)]",
|
||||
locked: "text-[var(--muted-foreground)]",
|
||||
pending: "text-[var(--muted-foreground)] opacity-50",
|
||||
};
|
||||
|
||||
function RowIcon({ state }: { state: RowState }) {
|
||||
const className = `shrink-0 ${ICON_CLASS[state]}`;
|
||||
if (state === "done") return <CheckCircle2 size={20} className={className} aria-hidden="true" />;
|
||||
if (state === "skipped") return <SkipForward size={20} className={className} aria-hidden="true" />;
|
||||
if (state === "locked") return <Lock size={20} className={className} aria-hidden="true" />;
|
||||
return <Circle size={20} className={className} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
const BADGE_CLASS =
|
||||
"rounded-full border border-[var(--border)] px-2 py-0.5 text-xs text-[var(--muted-foreground)]";
|
||||
|
||||
/**
|
||||
* Getting-started checklist (#352, #397): the welcome card of the empty
|
||||
* dashboard and the list that the banner unfolds. Pure: the only hook is
|
||||
* `useTranslation`, so the component is tested by calling it as a function.
|
||||
*
|
||||
* - A row still to do links to its `to` route with the step's navigation
|
||||
* state (`stepNavState`); a done row has no link.
|
||||
* - A skipped row shows a skip icon, the "Skipped" badge and "Resume", which
|
||||
* takes the step back, then opens its page like the link of a row to do.
|
||||
* - A locked row (Budget outside the edition) shows a lock, the "Base and up"
|
||||
* label and links to the license card (`/settings/users`), which carries no
|
||||
* step card: no navigation state.
|
||||
* - The icons are hidden from assistive technologies, so each state but "to
|
||||
* do" is read out by a visually hidden text after the label (#384); the
|
||||
* "Skipped" badge is hidden from them, that text already says it.
|
||||
*/
|
||||
export default function OnboardingChecklist({
|
||||
items,
|
||||
done,
|
||||
total,
|
||||
budgetReady,
|
||||
visitStepsInitialized,
|
||||
onHide,
|
||||
onResume,
|
||||
}: OnboardingChecklistProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={t("onboarding.checklist.title")}
|
||||
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<h3 className="font-semibold">{t("onboarding.checklist.title")}</h3>
|
||||
{budgetReady && visitStepsInitialized && (
|
||||
<span className="text-sm font-medium text-[var(--muted-foreground)] tabular-nums">
|
||||
{t("onboarding.checklist.counter", { done, total })}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onHide}
|
||||
className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors"
|
||||
>
|
||||
{t("onboarding.checklist.hide")}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => {
|
||||
const state = rowState(item, budgetReady, visitStepsInitialized);
|
||||
const label = t(`onboarding.checklist.items.${item.key}`);
|
||||
const stateText =
|
||||
state === "todo" ? null : (
|
||||
<span className="sr-only"> {t(`onboarding.state.${state}`)}</span>
|
||||
);
|
||||
return (
|
||||
<li key={item.key} className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<RowIcon state={state} />
|
||||
{state === "todo" && (
|
||||
<Link
|
||||
to={item.to}
|
||||
state={stepNavState(item.key)}
|
||||
className="hover:underline text-[var(--foreground)]"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)}
|
||||
{state === "skipped" && (
|
||||
<>
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{label}
|
||||
{stateText}
|
||||
</span>
|
||||
<span aria-hidden="true" className={BADGE_CLASS}>
|
||||
{t("onboarding.checklist.skippedBadge")}
|
||||
</span>
|
||||
<Link
|
||||
to={item.to}
|
||||
state={stepNavState(item.key)}
|
||||
onClick={() => onResume(item.key)}
|
||||
className="font-medium text-[var(--primary)] hover:underline"
|
||||
>
|
||||
{t("onboarding.checklist.resume")}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{state === "locked" && (
|
||||
<Link
|
||||
to={item.to}
|
||||
className="flex flex-wrap items-center gap-2 hover:underline text-[var(--foreground)]"
|
||||
>
|
||||
<span>
|
||||
{label}
|
||||
{stateText}
|
||||
</span>
|
||||
<span className={BADGE_CLASS}>{t("onboarding.checklist.lockedBudget")}</span>
|
||||
</Link>
|
||||
)}
|
||||
{state === "done" && (
|
||||
<span className="text-[var(--muted-foreground)] line-through">
|
||||
{label}
|
||||
{stateText}
|
||||
</span>
|
||||
)}
|
||||
{state === "pending" && (
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{label}
|
||||
{stateText}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
209
src/components/import/ImportEmptyState.test.tsx
Normal file
209
src/components/import/ImportEmptyState.test.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
// ImportEmptyState — unit tests (#356).
|
||||
//
|
||||
// FilterPanel.test.tsx pattern: no jsdom, `react-i18next` and `lucide-react`
|
||||
// mocked, the component called as a function and its element tree walked.
|
||||
// `t` returns the key, with the interpolation values appended, so the asserts
|
||||
// read the i18n keys and never a hard-coded string. The inner `EmptyState` is
|
||||
// expanded (called as a function too) so its buttons and links are visible.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
FolderOpen: () => null,
|
||||
Inbox: () => null,
|
||||
Folder: () => null,
|
||||
FileText: () => null,
|
||||
}));
|
||||
|
||||
import { FileText, Folder, FolderOpen, Inbox } from "lucide-react";
|
||||
import ImportEmptyState, {
|
||||
CSV_EXPORT_GUIDE_PATH,
|
||||
folderNameFromPath,
|
||||
type ImportEmptyStateProps,
|
||||
} from "./ImportEmptyState";
|
||||
|
||||
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
|
||||
|
||||
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
|
||||
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
|
||||
if (!isValidElement(node)) return [];
|
||||
const el = node as AnyElement;
|
||||
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
|
||||
}
|
||||
|
||||
function texts(node: ReactNode): string[] {
|
||||
if (Array.isArray(node)) return node.flatMap(texts);
|
||||
if (typeof node === "string") return [node];
|
||||
if (!isValidElement(node)) return [];
|
||||
return texts((node as AnyElement).props.children);
|
||||
}
|
||||
|
||||
// Leaves of the walk: react-router's Link and the mocked icons keep their type.
|
||||
const LEAVES = new Set<unknown>([Link, FolderOpen, Inbox, Folder, FileText]);
|
||||
|
||||
/** Resolves the inner `EmptyState` component so its buttons and links are visible. */
|
||||
function expand(node: ReactNode): ReactNode {
|
||||
if (Array.isArray(node)) return node.map(expand);
|
||||
if (!isValidElement(node)) return node;
|
||||
const el = node as AnyElement;
|
||||
if (typeof el.type === "function" && !LEAVES.has(el.type)) {
|
||||
const render = el.type as (props: Record<string, unknown>) => ReactNode;
|
||||
return expand(render(el.props));
|
||||
}
|
||||
return { ...el, props: { ...el.props, children: expand(el.props.children) } } as ReactNode;
|
||||
}
|
||||
|
||||
type ButtonEl = ReactElement<{ type: string; disabled: boolean; onClick: () => void; children?: ReactNode }>;
|
||||
type LinkEl = ReactElement<{ to: string; children?: ReactNode }>;
|
||||
|
||||
function render(overrides: Partial<ImportEmptyStateProps> = {}) {
|
||||
const props: ImportEmptyStateProps = {
|
||||
hasFolder: false,
|
||||
folderName: null,
|
||||
onBrowse: vi.fn(),
|
||||
onCreateSample: vi.fn(),
|
||||
onRefresh: vi.fn(),
|
||||
isCreatingSample: false,
|
||||
...overrides,
|
||||
};
|
||||
const tree = expand(ImportEmptyState(props));
|
||||
const buttons = find(tree, (e) => e.type === "button") as ButtonEl[];
|
||||
const links = find(tree, (e) => e.type === Link) as LinkEl[];
|
||||
return { props, tree, buttons, links };
|
||||
}
|
||||
|
||||
describe("ImportEmptyState — no folder (#356)", () => {
|
||||
it("offers one primary 'choose a folder' button wired to onBrowse, and the guide link", () => {
|
||||
const { props, tree, buttons, links } = render({ hasFolder: false });
|
||||
|
||||
expect(texts(tree)).toContain("t(import.empty.noFolder.title)");
|
||||
expect(texts(tree)).toContain("t(import.empty.noFolder.description)");
|
||||
|
||||
expect(buttons).toHaveLength(1);
|
||||
expect(texts(buttons[0].props.children)).toContain("t(import.empty.noFolder.cta)");
|
||||
expect(buttons[0].props.disabled).toBe(false);
|
||||
buttons[0].props.onClick();
|
||||
expect(props.onBrowse).toHaveBeenCalledTimes(1);
|
||||
expect(props.onCreateSample).not.toHaveBeenCalled();
|
||||
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0].props.to).toBe(CSV_EXPORT_GUIDE_PATH);
|
||||
expect(links[0].props.to).toBe("/settings/users#import-export");
|
||||
expect(texts(links[0].props.children)).toContain("t(import.empty.guideLink)");
|
||||
});
|
||||
|
||||
it("never shows the sample or refresh actions without a folder", () => {
|
||||
const { tree } = render({ hasFolder: false });
|
||||
expect(texts(tree)).not.toContain("t(import.empty.createSample)");
|
||||
expect(texts(tree)).not.toContain("t(import.empty.refresh)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ImportEmptyState — folder without sources (#356)", () => {
|
||||
it("offers 'create a sample folder' as primary, 'refresh' as secondary, and the guide link", () => {
|
||||
const { props, tree, buttons, links } = render({ hasFolder: true, folderName: "Relevés" });
|
||||
|
||||
expect(texts(tree)).toContain("t(import.empty.noSources.title)");
|
||||
|
||||
expect(buttons).toHaveLength(2);
|
||||
expect(texts(buttons[0].props.children)).toContain("t(import.empty.createSample)");
|
||||
expect(buttons[0].props.disabled).toBe(false);
|
||||
buttons[0].props.onClick();
|
||||
expect(props.onCreateSample).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(texts(buttons[1].props.children)).toContain("t(import.empty.refresh)");
|
||||
buttons[1].props.onClick();
|
||||
expect(props.onRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(props.onBrowse).not.toHaveBeenCalled();
|
||||
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0].props.to).toBe("/settings/users#import-export");
|
||||
expect(texts(links[0].props.children)).toContain("t(import.empty.guideLink)");
|
||||
});
|
||||
|
||||
it("disables the create button while the sample folders are being written", () => {
|
||||
const { buttons } = render({ hasFolder: true, folderName: "Relevés", isCreatingSample: true });
|
||||
expect(buttons[0].props.disabled).toBe(true);
|
||||
// Refresh stays available: it is not the action in flight.
|
||||
expect(buttons[1].props.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("names the folder in the description, never the full path", () => {
|
||||
const folderName = folderNameFromPath("C:\\Users\\max\\Documents\\Relevés");
|
||||
const { tree } = render({ hasFolder: true, folderName });
|
||||
const description = texts(tree).find((s) => s.startsWith("t(import.empty.noSources.description"));
|
||||
|
||||
expect(description).toBe("t(import.empty.noSources.description[folderName=Relevés])");
|
||||
expect(description).not.toContain("Users");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ImportEmptyState — expected structure of the folder (#422)", () => {
|
||||
const STRUCTURE = [
|
||||
"title",
|
||||
"parent",
|
||||
"account1",
|
||||
"file1",
|
||||
"file2",
|
||||
"account2",
|
||||
"rootNote",
|
||||
].map((name) => `t(import.empty.structure.${name})`);
|
||||
|
||||
it.each([
|
||||
["no folder", { hasFolder: false }],
|
||||
["a folder without sources", { hasFolder: true, folderName: "Relevés" }],
|
||||
] as const)("shows the expected structure with %s, above the guide link", (_, overrides) => {
|
||||
const { tree } = render(overrides);
|
||||
const all = texts(tree);
|
||||
for (const text of STRUCTURE) expect(all).toContain(text);
|
||||
expect(all.indexOf("t(import.empty.structure.rootNote)")).toBeLessThan(
|
||||
all.indexOf("t(import.empty.guideLink)"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ImportEmptyState — getting-started target (#398)", () => {
|
||||
const highlighted = (tree: ReactNode) =>
|
||||
find(tree, (e) => e.props["data-onboarding-highlight"] === "true");
|
||||
|
||||
it("highlights 'Choose a folder' without a folder, and only it", () => {
|
||||
const { tree } = render({ hasFolder: false, onboardingTarget: true });
|
||||
const targets = highlighted(tree);
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(targets[0].type).toBe("button");
|
||||
expect(texts(targets[0].props.children)).toContain("t(import.empty.noFolder.cta)");
|
||||
});
|
||||
|
||||
it("highlights 'Create a sample folder' in a folder without sources, not 'Refresh'", () => {
|
||||
const { tree } = render({ hasFolder: true, folderName: "Relevés", onboardingTarget: true });
|
||||
const targets = highlighted(tree);
|
||||
expect(targets).toHaveLength(1);
|
||||
expect(texts(targets[0].props.children)).toContain("t(import.empty.createSample)");
|
||||
});
|
||||
|
||||
it("highlights nothing without the property", () => {
|
||||
expect(highlighted(render({ hasFolder: false }).tree)).toHaveLength(0);
|
||||
expect(highlighted(render({ hasFolder: true, folderName: "Relevés" }).tree)).toHaveLength(0);
|
||||
expect(highlighted(render({ hasFolder: true, onboardingTarget: false }).tree)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("folderNameFromPath (#356)", () => {
|
||||
it("returns the last segment for POSIX and Windows paths", () => {
|
||||
expect(folderNameFromPath("/home/max/releves")).toBe("releves");
|
||||
expect(folderNameFromPath("C:\\Users\\max\\Relevés")).toBe("Relevés");
|
||||
});
|
||||
|
||||
it("ignores trailing separators", () => {
|
||||
expect(folderNameFromPath("/home/max/releves/")).toBe("releves");
|
||||
expect(folderNameFromPath("C:\\Users\\max\\Relevés\\")).toBe("Relevés");
|
||||
});
|
||||
|
||||
it("falls back to the path itself when it has no segment", () => {
|
||||
expect(folderNameFromPath("/")).toBe("/");
|
||||
expect(folderNameFromPath("")).toBe("");
|
||||
});
|
||||
});
|
||||
99
src/components/import/ImportEmptyState.tsx
Normal file
99
src/components/import/ImportEmptyState.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FolderOpen, Inbox } from "lucide-react";
|
||||
import EmptyState from "../shared/EmptyState";
|
||||
import ImportFolderStructure from "./ImportFolderStructure";
|
||||
|
||||
/** Where the user guide explains how to export a CSV from each bank (#355). */
|
||||
export const CSV_EXPORT_GUIDE_PATH = "/settings/users#import-export";
|
||||
|
||||
export interface ImportEmptyStateProps {
|
||||
/** An import folder is configured; the empty state then speaks of its content. */
|
||||
hasFolder: boolean;
|
||||
/** Last segment of the import folder path, never the full path. */
|
||||
folderName: string | null;
|
||||
onBrowse: () => void;
|
||||
onCreateSample: () => void;
|
||||
onRefresh: () => void;
|
||||
/** The sample folders are being written: the primary button is disabled. */
|
||||
isCreatingSample: boolean;
|
||||
/**
|
||||
* Highlight the primary action for the getting-started card (#398): "Choose
|
||||
* a folder" without a folder, "Create a sample folder" with one.
|
||||
*/
|
||||
onboardingTarget?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Last segment of a filesystem path, whichever separator the OS used, with
|
||||
* trailing separators ignored (`C:\Users\max\Relevés\` → `Relevés`). The full
|
||||
* path is already shown by the folder card; the empty state only names it.
|
||||
*/
|
||||
export function folderNameFromPath(path: string): string {
|
||||
const segments = path.split(/[\\/]+/).filter((s) => s.length > 0);
|
||||
return segments.length > 0 ? segments[segments.length - 1] : path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state of the import page (#356), replacing the empty `SourceList` in
|
||||
* both of its situations: no import folder yet (choose one), or a folder with
|
||||
* no account sub-folder (create the sample folders, or refresh after dropping
|
||||
* files). Both variants show the expected structure of the folder (#422) and
|
||||
* link to the bank CSV export guide. Pure: no hook besides `useTranslation`,
|
||||
* so it is tested by direct call.
|
||||
*/
|
||||
export default function ImportEmptyState({
|
||||
hasFolder,
|
||||
folderName,
|
||||
onBrowse,
|
||||
onCreateSample,
|
||||
onRefresh,
|
||||
isCreatingSample,
|
||||
onboardingTarget,
|
||||
}: ImportEmptyStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const details = (
|
||||
<>
|
||||
<ImportFolderStructure />
|
||||
<p className="mt-4 text-center">
|
||||
<Link
|
||||
to={CSV_EXPORT_GUIDE_PATH}
|
||||
className="text-sm text-[var(--primary)] hover:underline"
|
||||
>
|
||||
{t("import.empty.guideLink")}
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!hasFolder) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<FolderOpen size={40} />}
|
||||
title={t("import.empty.noFolder.title")}
|
||||
description={t("import.empty.noFolder.description")}
|
||||
primaryAction={{ label: t("import.empty.noFolder.cta"), onClick: onBrowse, onboardingTarget }}
|
||||
>
|
||||
{details}
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<Inbox size={40} />}
|
||||
title={t("import.empty.noSources.title")}
|
||||
description={t("import.empty.noSources.description", { folderName: folderName ?? "" })}
|
||||
primaryAction={{
|
||||
label: t("import.empty.createSample"),
|
||||
onClick: onCreateSample,
|
||||
disabled: isCreatingSample,
|
||||
onboardingTarget,
|
||||
}}
|
||||
secondaryAction={{ label: t("import.empty.refresh"), onClick: onRefresh }}
|
||||
>
|
||||
{details}
|
||||
</EmptyState>
|
||||
);
|
||||
}
|
||||
|
|
@ -50,9 +50,18 @@ export default function ImportFolderConfig({
|
|||
{t("import.folder.refresh")}
|
||||
</button>
|
||||
)}
|
||||
{/*
|
||||
Without a folder the empty state below carries the primary "choose
|
||||
a folder" button (#356); this one steps back to the secondary style
|
||||
so a single primary button is on screen.
|
||||
*/}
|
||||
<button
|
||||
onClick={onBrowse}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity"
|
||||
className={
|
||||
folderPath
|
||||
? "flex items-center gap-1 px-3 py-1.5 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity"
|
||||
: "flex items-center gap-1 px-3 py-1.5 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
{t("import.folder.browse")}
|
||||
|
|
|
|||
62
src/components/import/ImportFolderStructure.test.tsx
Normal file
62
src/components/import/ImportFolderStructure.test.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// ImportFolderStructure — render tests (#422).
|
||||
//
|
||||
// `renderToStaticMarkup`, no jsdom; the shared marked `t` (#389), so every
|
||||
// text is asserted in its `t(key)` form and a string that skipped `t` fails.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||
|
||||
import ImportFolderStructure from "./ImportFolderStructure";
|
||||
|
||||
const KEYS = [
|
||||
"title",
|
||||
"parent",
|
||||
"account1",
|
||||
"file1",
|
||||
"file2",
|
||||
"account2",
|
||||
"rootNote",
|
||||
].map((name) => `import.empty.structure.${name}`);
|
||||
|
||||
/** The markup with the class attributes dropped, so the structure reads. */
|
||||
function bare(): string {
|
||||
return renderToStaticMarkup(<ImportFolderStructure />)
|
||||
.replace(/ class="[^"]*"/g, "")
|
||||
.replace(/<svg[^>]*>.*?<\/svg>/g, "<svg/>");
|
||||
}
|
||||
|
||||
describe("ImportFolderStructure (#422)", () => {
|
||||
it("renders its seven texts, each through t, in reading order", () => {
|
||||
const html = renderToStaticMarkup(<ImportFolderStructure />);
|
||||
const positions = KEYS.map((key) => html.indexOf(`t(${key})`));
|
||||
for (const [i, position] of positions.entries()) {
|
||||
expect(position, KEYS[i]).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
expect([...positions].sort((a, b) => a - b)).toEqual(positions);
|
||||
});
|
||||
|
||||
it("nests the levels: parent folder > account folders > files", () => {
|
||||
// The two files sit in a list inside the first account's item, itself in
|
||||
// a list inside the parent's item; the second account is the first's
|
||||
// sibling, and the root note is outside the tree.
|
||||
expect(bare()).toBe(
|
||||
"<div><h3>t(import.empty.structure.title)</h3>" +
|
||||
"<ul><li><span><svg/>t(import.empty.structure.parent)</span>" +
|
||||
"<ul>" +
|
||||
"<li><span><svg/>t(import.empty.structure.account1)</span>" +
|
||||
"<ul><li><svg/>t(import.empty.structure.file1)</li><li><svg/>t(import.empty.structure.file2)</li></ul>" +
|
||||
"</li>" +
|
||||
"<li><span><svg/>t(import.empty.structure.account2)</span></li>" +
|
||||
"</ul></li></ul>" +
|
||||
"<p>t(import.empty.structure.rootNote)</p></div>",
|
||||
);
|
||||
});
|
||||
|
||||
it("hides every icon from assistive technologies", () => {
|
||||
const html = renderToStaticMarkup(<ImportFolderStructure />);
|
||||
const icons = html.match(/<svg[^>]*>/g) ?? [];
|
||||
expect(icons).toHaveLength(5);
|
||||
for (const icon of icons) expect(icon).toContain('aria-hidden="true"');
|
||||
});
|
||||
});
|
||||
59
src/components/import/ImportFolderStructure.tsx
Normal file
59
src/components/import/ImportFolderStructure.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { FileText, Folder } from "lucide-react";
|
||||
|
||||
const ITEM_CLASS = "flex items-center gap-2";
|
||||
const NESTED_CLASS = "mt-1 ml-5 space-y-1 border-l border-[var(--border)] pl-3";
|
||||
|
||||
/**
|
||||
* "Expected structure" box of the import page's empty state (#422): the
|
||||
* folder to choose is the parent one, each of its sub-folders is one source
|
||||
* (one account), and only the files inside a sub-folder are read. It mirrors
|
||||
* `scan_import_folder` (src-tauri/src/commands/fs_commands.rs): direct,
|
||||
* non-hidden sub-folders become sources, files at the root are ignored.
|
||||
*
|
||||
* A nested list rather than a picture, so a screen reader reads the levels;
|
||||
* the icons are decorative. Pure: no hook besides `useTranslation`, so it is
|
||||
* tested by direct call.
|
||||
*/
|
||||
export default function ImportFolderStructure() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--muted)] p-4 text-sm text-[var(--foreground)]">
|
||||
<h3 className="mb-2 font-semibold">{t("import.empty.structure.title")}</h3>
|
||||
<ul className="space-y-1">
|
||||
<li>
|
||||
<span className={`${ITEM_CLASS} font-medium`}>
|
||||
<Folder size={16} aria-hidden="true" className="shrink-0 text-[var(--primary)]" />
|
||||
{t("import.empty.structure.parent")}
|
||||
</span>
|
||||
<ul className={NESTED_CLASS}>
|
||||
<li>
|
||||
<span className={ITEM_CLASS}>
|
||||
<Folder size={16} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
|
||||
{t("import.empty.structure.account1")}
|
||||
</span>
|
||||
<ul className={NESTED_CLASS}>
|
||||
<li className={ITEM_CLASS}>
|
||||
<FileText size={16} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
|
||||
{t("import.empty.structure.file1")}
|
||||
</li>
|
||||
<li className={ITEM_CLASS}>
|
||||
<FileText size={16} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
|
||||
{t("import.empty.structure.file2")}
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<span className={ITEM_CLASS}>
|
||||
<Folder size={16} aria-hidden="true" className="shrink-0 text-[var(--muted-foreground)]" />
|
||||
{t("import.empty.structure.account2")}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-3 text-[var(--muted-foreground)]">{t("import.empty.structure.rootNote")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
src/components/import/RootFilesWarning.locales.test.tsx
Normal file
55
src/components/import/RootFilesWarning.locales.test.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// RootFilesWarning (#427) through the REAL locales: the plural forms
|
||||
// (`message_one`/`message_other`, `more_one`/`more_other`) and the quotes
|
||||
// around the folder name only exist in fr.json and en.json, which the marked
|
||||
// `t` of RootFilesWarning.test.tsx never reads.
|
||||
import { describe, it, expect, afterAll } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import i18n from "../../i18n/config";
|
||||
import RootFilesWarning from "./RootFilesWarning";
|
||||
|
||||
const text = (names: string[]) =>
|
||||
renderToStaticMarkup(<RootFilesWarning names={names} folderName="Relevés" />)
|
||||
.replace(/<[^>]+>/g, "");
|
||||
|
||||
const SEVEN = ["1.csv", "2.csv", "3.csv", "4.csv", "5.csv", "6.csv", "7.csv"];
|
||||
|
||||
afterAll(async () => {
|
||||
await i18n.changeLanguage("fr");
|
||||
});
|
||||
|
||||
describe("RootFilesWarning — French (#427)", () => {
|
||||
it("singular", async () => {
|
||||
await i18n.changeLanguage("fr");
|
||||
expect(text(["a.csv"])).toBe(
|
||||
"1 fichier est posé directement dans « Relevés » et est ignoré : a.csv. Déplacez-le dans un sous-dossier par compte.",
|
||||
);
|
||||
});
|
||||
|
||||
it("plural, five names then « et 2 autres »", async () => {
|
||||
await i18n.changeLanguage("fr");
|
||||
expect(text(SEVEN)).toBe(
|
||||
"7 fichiers sont posés directement dans « Relevés » et sont ignorés : 1.csv, 2.csv, 3.csv, 4.csv, 5.csv et 2 autres. Déplacez-les dans un sous-dossier par compte.",
|
||||
);
|
||||
});
|
||||
|
||||
it("one more: « et 1 autre »", async () => {
|
||||
await i18n.changeLanguage("fr");
|
||||
expect(text(SEVEN.slice(0, 6))).toContain("5.csv et 1 autre.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RootFilesWarning — English (#427)", () => {
|
||||
it("singular", async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
expect(text(["a.csv"])).toBe(
|
||||
"1 file sits directly in “Relevés” and is ignored: a.csv. Move it into a subfolder per account.",
|
||||
);
|
||||
});
|
||||
|
||||
it("plural, five names then “and 2 more”", async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
expect(text(SEVEN)).toBe(
|
||||
"7 files sit directly in “Relevés” and are ignored: 1.csv, 2.csv, 3.csv, 4.csv, 5.csv and 2 more. Move them into a subfolder per account.",
|
||||
);
|
||||
});
|
||||
});
|
||||
39
src/components/import/RootFilesWarning.test.tsx
Normal file
39
src/components/import/RootFilesWarning.test.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// RootFilesWarning (#427). No jsdom: rendered to static markup with the shared
|
||||
// marked `t`, so the message key, its count and its interpolations are read.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||
|
||||
import RootFilesWarning from "./RootFilesWarning";
|
||||
|
||||
const render = (names: string[], folderName = "Relevés") =>
|
||||
renderToStaticMarkup(<RootFilesWarning names={names} folderName={folderName} />);
|
||||
|
||||
describe("RootFilesWarning (#427)", () => {
|
||||
it("one file: the message with count 1, the folder name and the file", () => {
|
||||
expect(render(["a.csv"])).toContain(
|
||||
"t(import.rootFiles.message[count=1,folder=Relevés,names=a.csv])",
|
||||
);
|
||||
});
|
||||
|
||||
it("three files: the message with count 3 and the names joined by a comma", () => {
|
||||
expect(render(["a.csv", "b.csv", "c.txt"])).toContain(
|
||||
"t(import.rootFiles.message[count=3,folder=Relevés,names=a.csv, b.csv, c.txt])",
|
||||
);
|
||||
});
|
||||
|
||||
it("seven files: five names, then 'and 2 more'", () => {
|
||||
const names = ["1.csv", "2.csv", "3.csv", "4.csv", "5.csv", "6.csv", "7.csv"];
|
||||
expect(render(names)).toContain(
|
||||
"t(import.rootFiles.message[count=7,folder=Relevés,names=1.csv, 2.csv, 3.csv, 4.csv, 5.csv t(import.rootFiles.more[count=2])])",
|
||||
);
|
||||
});
|
||||
|
||||
it("an icon hidden from assistive tech, and no live region", () => {
|
||||
const html = render(["a.csv"]);
|
||||
expect(html).toMatch(/<svg[^>]*aria-hidden="true"/);
|
||||
expect(html).not.toContain("role=");
|
||||
expect(html).not.toContain("aria-live");
|
||||
});
|
||||
});
|
||||
37
src/components/import/RootFilesWarning.tsx
Normal file
37
src/components/import/RootFilesWarning.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { formatRootFiles } from "../../utils/rootFiles";
|
||||
|
||||
export interface RootFilesWarningProps {
|
||||
/** Names of the CSV/TXT files placed directly at the root, sorted by the scan. */
|
||||
names: readonly string[];
|
||||
/** Last segment of the import folder path, never the full path. */
|
||||
folderName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Warning about the statement files placed directly in the import folder,
|
||||
* which no source reads (#427). Names up to five of them, then counts the
|
||||
* rest. Shown when the page loads, so no live region. On screen only: the
|
||||
* names never reach the logs. Pure besides `useTranslation`, tested by direct
|
||||
* render.
|
||||
*/
|
||||
export default function RootFilesWarning({ names, folderName }: RootFilesWarningProps) {
|
||||
const { t } = useTranslation();
|
||||
const { shown, more } = formatRootFiles(names);
|
||||
const listed =
|
||||
shown.join(", ") + (more > 0 ? ` ${t("import.rootFiles.more", { count: more })}` : "");
|
||||
|
||||
return (
|
||||
<div className="mb-4 p-3 rounded-xl bg-[var(--card)] border-2 border-[var(--accent)] flex items-start gap-2">
|
||||
<AlertTriangle size={16} aria-hidden="true" className="text-[var(--accent)] shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-[var(--foreground)]">
|
||||
{t("import.rootFiles.message", {
|
||||
count: names.length,
|
||||
folder: folderName,
|
||||
names: listed,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +1,26 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen, FileText, CheckCircle } from "lucide-react";
|
||||
import type { ScannedSource } from "../../shared/types";
|
||||
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
|
||||
|
||||
interface SourceCardProps {
|
||||
source: ScannedSource;
|
||||
isConfigured: boolean;
|
||||
newFileCount: number;
|
||||
onClick: () => void;
|
||||
/** Highlight this card for the getting-started card (#398). */
|
||||
onboardingTarget?: boolean;
|
||||
}
|
||||
|
||||
/** `data-onboarding-target` of a highlighted source card. */
|
||||
export const SOURCE_CARD_TARGET = "source-card";
|
||||
|
||||
export default function SourceCard({
|
||||
source,
|
||||
isConfigured,
|
||||
newFileCount,
|
||||
onClick,
|
||||
onboardingTarget,
|
||||
}: SourceCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -21,6 +28,7 @@ export default function SourceCard({
|
|||
<button
|
||||
onClick={onClick}
|
||||
className="w-full text-left bg-[var(--card)] rounded-xl p-4 border border-[var(--border)] hover:border-[var(--primary)] hover:shadow-sm transition-all"
|
||||
{...onboardingTargetAttributes(SOURCE_CARD_TARGET, onboardingTarget)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
44
src/components/import/SourceConfigPanel.test.tsx
Normal file
44
src/components/import/SourceConfigPanel.test.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// SourceConfigPanel — the source name is read-only, the subfolder's (#426).
|
||||
// No jsdom: rendered to static markup with the shared marked `t`.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||
|
||||
import SourceConfigPanel from "./SourceConfigPanel";
|
||||
import { initialWizardState } from "../../hooks/useImportWizard";
|
||||
|
||||
const noop = () => undefined;
|
||||
|
||||
const render = () =>
|
||||
renderToStaticMarkup(
|
||||
<SourceConfigPanel
|
||||
source={{ folder_name: "Desjardins", folder_path: "/releves/Desjardins", files: [] }}
|
||||
config={{ ...initialWizardState.sourceConfig, name: "Mon compte chèques" }}
|
||||
selectedFiles={[]}
|
||||
headers={[]}
|
||||
configTemplates={[]}
|
||||
onConfigChange={noop}
|
||||
onFileToggle={noop}
|
||||
onSelectAllFiles={noop}
|
||||
onAutoDetect={noop}
|
||||
onSaveAsTemplate={noop}
|
||||
onApplyTemplate={noop}
|
||||
onUpdateTemplate={noop}
|
||||
onDeleteTemplate={noop}
|
||||
selectedTemplateId={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe("SourceConfigPanel — source name (#426)", () => {
|
||||
it("shows the subfolder name with its hint", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("t(import.config.sourceNameHint)");
|
||||
expect(html).toMatch(/<p[^>]*>Desjardins<\/p>/);
|
||||
});
|
||||
|
||||
it("has no field holding the configured name", () => {
|
||||
const html = render();
|
||||
expect(html).not.toContain("Mon compte chèques");
|
||||
});
|
||||
});
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
type BankSignatureId,
|
||||
} from "../../utils/bankSignatures";
|
||||
import ColumnMappingEditor from "./ColumnMappingEditor";
|
||||
import SourceNameDisplay from "./SourceNameDisplay";
|
||||
|
||||
interface SourceConfigPanelProps {
|
||||
source: ScannedSource;
|
||||
|
|
@ -249,18 +250,8 @@ export default function SourceConfigPanel({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Source name */}
|
||||
<div>
|
||||
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
|
||||
{t("import.config.sourceName")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.name}
|
||||
onChange={(e) => updateConfig({ name: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
{/* Source name: the subfolder name, read-only (#426) */}
|
||||
<SourceNameDisplay folderName={source.folder_name} />
|
||||
|
||||
{/* Basic settings */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
|
|
|
|||
81
src/components/import/SourceList.test.tsx
Normal file
81
src/components/import/SourceList.test.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// SourceList and SourceCard — the getting-started target (#398). No jsdom:
|
||||
// react-dom/server renders in vitest's node environment, and the markup says
|
||||
// which card carries the highlight.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||
|
||||
import SourceList from "./SourceList";
|
||||
import { SOURCE_CARD_TARGET } from "./SourceCard";
|
||||
import type { ScannedSource } from "../../shared/types";
|
||||
|
||||
const HIGHLIGHT = 'data-onboarding-highlight="true"';
|
||||
|
||||
const source = (name: string): ScannedSource => ({
|
||||
folder_name: name,
|
||||
folder_path: `/releves/${name}`,
|
||||
files: [{ filename: "releve.csv", file_path: `/releves/${name}/releve.csv`, size_bytes: 10, modified_at: "" }],
|
||||
});
|
||||
|
||||
const SOURCES = [source("Desjardins"), source("Tangerine"), source("RBC")];
|
||||
|
||||
const markup = (onboardingTarget?: boolean, sources: ScannedSource[] = SOURCES) =>
|
||||
renderToStaticMarkup(
|
||||
<SourceList
|
||||
sources={sources}
|
||||
configuredSourceNames={new Set()}
|
||||
importedFileNames={new Map()}
|
||||
onSelectSource={vi.fn()}
|
||||
onboardingTarget={onboardingTarget}
|
||||
/>,
|
||||
);
|
||||
|
||||
/** The source cards, in order: each is a button. */
|
||||
const cards = (html: string) => html.split("<button").slice(1);
|
||||
|
||||
describe("SourceList — getting-started target (#398)", () => {
|
||||
it("highlights the first card of the list, and only it", () => {
|
||||
const html = markup(true);
|
||||
expect(html.split(HIGHLIGHT).length - 1).toBe(1);
|
||||
const [first, ...others] = cards(html);
|
||||
expect(first).toContain(HIGHLIGHT);
|
||||
expect(first).toContain(`data-onboarding-target="${SOURCE_CARD_TARGET}"`);
|
||||
expect(first).toContain("Desjardins");
|
||||
expect(others).toHaveLength(2);
|
||||
for (const card of others) expect(card).not.toContain("data-onboarding");
|
||||
});
|
||||
|
||||
it("highlights the only card of a one-source list", () => {
|
||||
expect(markup(true, [source("Desjardins")]).split(HIGHLIGHT).length - 1).toBe(1);
|
||||
});
|
||||
|
||||
it("highlights nothing without the property", () => {
|
||||
expect(markup()).not.toContain("data-onboarding");
|
||||
expect(markup(false)).not.toContain("data-onboarding");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SourceList and SourceCard — displayed texts (#389)", () => {
|
||||
it("titles the list and counts each source's files through t", () => {
|
||||
const html = markup();
|
||||
expect(html).toContain(">t(import.sources.title)<");
|
||||
expect(cards(html)[0]).toContain("t(import.sources.fileCount[count=1])");
|
||||
});
|
||||
|
||||
it("marks the files not yet imported through t", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SourceList
|
||||
sources={[source("Desjardins")]}
|
||||
configuredSourceNames={new Set()}
|
||||
importedFileNames={new Map([["Desjardins", new Set<string>()]])}
|
||||
onSelectSource={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("t(import.sources.new)");
|
||||
});
|
||||
|
||||
it("says the folder is empty through t", () => {
|
||||
expect(markup(false, [])).toContain(">t(import.sources.empty)<");
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,8 @@ interface SourceListProps {
|
|||
configuredSourceNames: Set<string>;
|
||||
importedFileNames: Map<string, Set<string>>;
|
||||
onSelectSource: (source: ScannedSource) => void;
|
||||
/** Highlight the first card for the getting-started card (#398): one target at a time. */
|
||||
onboardingTarget?: boolean;
|
||||
}
|
||||
|
||||
export default function SourceList({
|
||||
|
|
@ -15,6 +17,7 @@ export default function SourceList({
|
|||
configuredSourceNames,
|
||||
importedFileNames,
|
||||
onSelectSource,
|
||||
onboardingTarget,
|
||||
}: SourceListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -38,7 +41,7 @@ export default function SourceList({
|
|||
{t("import.sources.title")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sources.map((source) => {
|
||||
{sources.map((source, index) => {
|
||||
const isConfigured = configuredSourceNames.has(source.folder_name);
|
||||
// Count files not yet imported for this source
|
||||
const sourceHashes = importedFileNames.get(source.folder_name);
|
||||
|
|
@ -55,6 +58,7 @@ export default function SourceList({
|
|||
isConfigured={isConfigured}
|
||||
newFileCount={newFileCount}
|
||||
onClick={() => onSelectSource(source)}
|
||||
onboardingTarget={onboardingTarget && index === 0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
27
src/components/import/SourceNameDisplay.test.tsx
Normal file
27
src/components/import/SourceNameDisplay.test.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// SourceNameDisplay (#426). No jsdom: rendered to static markup with the shared
|
||||
// marked `t`, so the label and hint keys are read as written.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||
|
||||
import SourceNameDisplay from "./SourceNameDisplay";
|
||||
|
||||
const render = (folderName: string) =>
|
||||
renderToStaticMarkup(<SourceNameDisplay folderName={folderName} />);
|
||||
|
||||
describe("SourceNameDisplay (#426)", () => {
|
||||
it("shows the subfolder name, the label and the hint", () => {
|
||||
const html = render("Desjardins chèques");
|
||||
expect(html).toContain("Desjardins chèques");
|
||||
expect(html).toContain("t(import.config.sourceName)");
|
||||
expect(html).toContain("t(import.config.sourceNameHint)");
|
||||
});
|
||||
|
||||
it("has no input field: the name cannot be edited", () => {
|
||||
const html = render("Tangerine");
|
||||
expect(html).not.toContain("<input");
|
||||
expect(html).not.toContain("<textarea");
|
||||
expect(html).not.toContain("contenteditable");
|
||||
});
|
||||
});
|
||||
26
src/components/import/SourceNameDisplay.tsx
Normal file
26
src/components/import/SourceNameDisplay.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SourceNameDisplayProps {
|
||||
/** Name of the source subfolder, which is the source name (#426). */
|
||||
folderName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only source name in the source configuration (#426). A source is
|
||||
* looked up by its subfolder name on the next import, so its name is that
|
||||
* of the subfolder and cannot be edited.
|
||||
*/
|
||||
export default function SourceNameDisplay({ folderName }: SourceNameDisplayProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
<span className="block text-sm text-[var(--muted-foreground)] mb-1">
|
||||
{t("import.config.sourceName")}
|
||||
</span>
|
||||
<p className="text-sm font-medium text-[var(--foreground)] break-words">{folderName}</p>
|
||||
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||
{t("import.config.sourceNameHint")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
src/components/import/WizardNavigation.test.tsx
Normal file
53
src/components/import/WizardNavigation.test.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// WizardNavigation — the getting-started target (#398). No jsdom:
|
||||
// react-dom/server renders in vitest's node environment, and the markup says
|
||||
// which button carries the highlight.
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("react-i18next", async () => (await import("../../test/i18nMock")).i18nMock());
|
||||
|
||||
import WizardNavigation, { WIZARD_NEXT_TARGET } from "./WizardNavigation";
|
||||
|
||||
const HIGHLIGHT = 'data-onboarding-highlight="true"';
|
||||
|
||||
const markup = (props: Parameters<typeof WizardNavigation>[0]) =>
|
||||
renderToStaticMarkup(
|
||||
<WizardNavigation
|
||||
onBack={vi.fn()}
|
||||
onNext={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
nextLabel="NEXT_LABEL"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
|
||||
/** The buttons, in order: cancel, back, next. */
|
||||
const buttons = (html: string) => html.split("<button").slice(1);
|
||||
|
||||
describe("WizardNavigation — getting-started target (#398)", () => {
|
||||
it("highlights the next button, and neither back nor cancel", () => {
|
||||
const html = markup({ onboardingTarget: true });
|
||||
expect(html.split(HIGHLIGHT).length - 1).toBe(1);
|
||||
const all = buttons(html);
|
||||
expect(all).toHaveLength(3);
|
||||
const [cancel, back, next] = all;
|
||||
expect(next).toContain("NEXT_LABEL");
|
||||
expect(next).toContain(HIGHLIGHT);
|
||||
expect(next).toContain(`data-onboarding-target="${WIZARD_NEXT_TARGET}"`);
|
||||
expect(cancel).toContain("t(common.cancel)");
|
||||
expect(cancel).not.toContain("data-onboarding");
|
||||
expect(back).toContain("t(import.wizard.back)");
|
||||
expect(back).not.toContain("data-onboarding");
|
||||
});
|
||||
|
||||
it("keeps the highlight on a disabled next button", () => {
|
||||
const html = markup({ onboardingTarget: true, nextDisabled: true });
|
||||
expect(html.split(HIGHLIGHT).length - 1).toBe(1);
|
||||
});
|
||||
|
||||
it("highlights nothing without the property, or without a next button", () => {
|
||||
expect(markup({})).not.toContain("data-onboarding");
|
||||
expect(markup({ onboardingTarget: false })).not.toContain("data-onboarding");
|
||||
expect(markup({ onboardingTarget: true, showNext: false })).not.toContain("data-onboarding");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue