-
-
-
-
-
- {t("settings.categoriesCard.standardGuideTitle")}
-
-
- {t("settings.categoriesCard.standardGuideDescription")}
-
+
+
+
+
+
+
+
+
+
+ {t("settings.categoriesCard.standardGuideTitle")}
+
+
+ {t("settings.categoriesCard.standardGuideDescription")}
+
+
+
-
-
-
+
+
+ {showMigrate && (
+
+
+
+
+
+
+
+
+ {t("settings.categoriesCard.migrateTitle")}
+
+
+ {t("settings.categoriesCard.migrateDescription")}
+
+
+
+
+
+
+ )}
+
);
}
diff --git a/src/hooks/useCategoryMigration.test.ts b/src/hooks/useCategoryMigration.test.ts
new file mode 100644
index 0000000..6129d6e
--- /dev/null
+++ b/src/hooks/useCategoryMigration.test.ts
@@ -0,0 +1,191 @@
+import { describe, it, expect } from "vitest";
+import { migrationReducer, INITIAL_STATE } from "./useCategoryMigration";
+import type {
+ MigrationPlan,
+ MappingRow,
+} from "../services/categoryMappingService";
+import type { BackupResult } from "../services/categoryBackupService";
+import type { MigrationOutcome } from "../services/categoryMigrationService";
+
+// ---------------------------------------------------------------------------
+// Fixture helpers
+// ---------------------------------------------------------------------------
+
+function makeRow(v2: number, v1: number | null): MappingRow {
+ return {
+ v2CategoryId: v2,
+ v2CategoryName: `v2-${v2}`,
+ v1TargetId: v1,
+ v1TargetName: v1 === null ? null : `v1-${v1}`,
+ confidence: v1 === null ? "none" : "high",
+ reason: v1 === null ? "review" : "keyword",
+ };
+}
+
+function makePlan(rows: MappingRow[], preserved: MappingRow[] = []): MigrationPlan {
+ const unresolved = rows.filter((r) => r.v1TargetId === null);
+ return {
+ rows,
+ preserved,
+ unresolved,
+ stats: {
+ total: rows.length,
+ high: rows.filter((r) => r.confidence === "high").length,
+ medium: rows.filter((r) => r.confidence === "medium").length,
+ low: rows.filter((r) => r.confidence === "low").length,
+ none: unresolved.length,
+ },
+ };
+}
+
+const FAKE_BACKUP: BackupResult = {
+ path: "/tmp/backup.sref",
+ size: 1024,
+ checksum: "abc",
+ verifiedAt: new Date().toISOString(),
+ encrypted: false,
+};
+
+const FAKE_OUTCOME: MigrationOutcome = {
+ succeeded: true,
+ insertedV1Count: 139,
+ updatedTransactionsCount: 100,
+ updatedBudgetsCount: 10,
+ updatedKeywordsCount: 20,
+ deletedV2Count: 40,
+ customPreservedCount: 2,
+ backupPath: "/tmp/backup.sref",
+};
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("migrationReducer", () => {
+ it("starts on discover with no plan / backup / outcome", () => {
+ expect(INITIAL_STATE.step).toBe("discover");
+ expect(INITIAL_STATE.plan).toBeNull();
+ expect(INITIAL_STATE.backup).toBeNull();
+ expect(INITIAL_STATE.outcome).toBeNull();
+ expect(INITIAL_STATE.unresolved).toBe(0);
+ });
+
+ it("LOAD_PLAN stores plan and counts unresolved rows", () => {
+ const plan = makePlan([makeRow(10, 1011), makeRow(11, null)]);
+ const next = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
+ expect(next.plan).toBe(plan);
+ expect(next.unresolved).toBe(1);
+ expect(next.errors).toEqual([]);
+ });
+
+ it("RESOLVE_ROW sets target, bumps confidence from none to medium, decreases unresolved", () => {
+ const plan = makePlan([makeRow(10, null), makeRow(11, null)]);
+ const s1 = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
+ expect(s1.unresolved).toBe(2);
+ const s2 = migrationReducer(s1, {
+ type: "RESOLVE_ROW",
+ v2CategoryId: 10,
+ v1TargetId: 1011,
+ v1TargetName: "Paie régulière",
+ });
+ expect(s2.unresolved).toBe(1);
+ const resolved = s2.plan!.rows.find((r) => r.v2CategoryId === 10)!;
+ expect(resolved.v1TargetId).toBe(1011);
+ expect(resolved.v1TargetName).toBe("Paie régulière");
+ expect(resolved.confidence).toBe("medium");
+ });
+
+ it("GO_NEXT blocks simulate -> consent when unresolved > 0", () => {
+ const plan = makePlan([makeRow(10, null)]);
+ let s = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
+ s = migrationReducer(s, { type: "GO_NEXT" }); // discover -> simulate
+ expect(s.step).toBe("simulate");
+ const sBlocked = migrationReducer(s, { type: "GO_NEXT" });
+ expect(sBlocked.step).toBe("simulate"); // blocked because unresolved=1
+ });
+
+ it("GO_NEXT advances simulate -> consent once all rows are resolved", () => {
+ const plan = makePlan([makeRow(10, null)]);
+ let s = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
+ s = migrationReducer(s, { type: "GO_NEXT" }); // simulate
+ s = migrationReducer(s, {
+ type: "RESOLVE_ROW",
+ v2CategoryId: 10,
+ v1TargetId: 1011,
+ v1TargetName: "Paie régulière",
+ });
+ s = migrationReducer(s, { type: "GO_NEXT" }); // consent
+ expect(s.step).toBe("consent");
+ });
+
+ it("GO_BACK goes simulate -> discover and consent -> simulate", () => {
+ const plan = makePlan([makeRow(10, 1011)]);
+ let s = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
+ s = migrationReducer(s, { type: "GO_NEXT" }); // simulate
+ s = migrationReducer(s, { type: "GO_NEXT" }); // consent
+ expect(s.step).toBe("consent");
+ s = migrationReducer(s, { type: "GO_BACK" });
+ expect(s.step).toBe("simulate");
+ s = migrationReducer(s, { type: "GO_BACK" });
+ expect(s.step).toBe("discover");
+ });
+
+ it("START_RUN moves the step to running and clears errors", () => {
+ const base = { ...INITIAL_STATE, step: "consent" as const, errors: ["old"] };
+ const s = migrationReducer(base, { type: "START_RUN" });
+ expect(s.step).toBe("running");
+ expect(s.errors).toEqual([]);
+ });
+
+ it("SET_BACKUP stores the backup and keeps the current step", () => {
+ const base = { ...INITIAL_STATE, step: "running" as const };
+ const s = migrationReducer(base, { type: "SET_BACKUP", backup: FAKE_BACKUP });
+ expect(s.backup).toBe(FAKE_BACKUP);
+ expect(s.step).toBe("running");
+ });
+
+ it("SET_OUTCOME moves to success on succeeded=true", () => {
+ const base = { ...INITIAL_STATE, step: "running" as const };
+ const s = migrationReducer(base, {
+ type: "SET_OUTCOME",
+ outcome: FAKE_OUTCOME,
+ });
+ expect(s.step).toBe("success");
+ expect(s.outcome).toBe(FAKE_OUTCOME);
+ expect(s.errors).toEqual([]);
+ });
+
+ it("SET_OUTCOME moves to error when outcome.succeeded=false and records the error", () => {
+ const failed: MigrationOutcome = { ...FAKE_OUTCOME, succeeded: false, error: "sql" };
+ const base = { ...INITIAL_STATE, step: "running" as const };
+ const s = migrationReducer(base, { type: "SET_OUTCOME", outcome: failed });
+ expect(s.step).toBe("error");
+ expect(s.outcome).toBe(failed);
+ expect(s.errors).toContain("sql");
+ });
+
+ it("FAIL appends to errors and moves to error step", () => {
+ const base = { ...INITIAL_STATE, step: "running" as const };
+ const s = migrationReducer(base, { type: "FAIL", error: "backup failed" });
+ expect(s.step).toBe("error");
+ expect(s.errors).toEqual(["backup failed"]);
+ });
+
+ it("SELECT_ROW stores the selected row id", () => {
+ const s1 = migrationReducer(INITIAL_STATE, { type: "SELECT_ROW", v2CategoryId: 42 });
+ expect(s1.selectedRowV2Id).toBe(42);
+ const s2 = migrationReducer(s1, { type: "SELECT_ROW", v2CategoryId: null });
+ expect(s2.selectedRowV2Id).toBeNull();
+ });
+
+ it("RESET returns the initial state", () => {
+ const middle = {
+ ...INITIAL_STATE,
+ step: "success" as const,
+ errors: ["x"],
+ outcome: FAKE_OUTCOME,
+ };
+ const s = migrationReducer(middle, { type: "RESET" });
+ expect(s).toEqual(INITIAL_STATE);
+ });
+});
diff --git a/src/hooks/useCategoryMigration.ts b/src/hooks/useCategoryMigration.ts
new file mode 100644
index 0000000..5c9dc52
--- /dev/null
+++ b/src/hooks/useCategoryMigration.ts
@@ -0,0 +1,257 @@
+import { useCallback, useReducer } from "react";
+import type {
+ MigrationPlan,
+ MappingRow,
+} from "../services/categoryMappingService";
+import type { BackupResult } from "../services/categoryBackupService";
+import type { MigrationOutcome } from "../services/categoryMigrationService";
+
+// -----------------------------------------------------------------------------
+// useCategoryMigration — useReducer-backed state machine for the 3-step page.
+//
+// State model:
+// - step : which screen is rendered (discover → simulate → consent → running → success | error)
+// - plan : MigrationPlan produced by categoryMappingService (memory only)
+// - backup : BackupResult once the pre-migration SREF is written (null otherwise)
+// - outcome : MigrationOutcome produced by applyMigration (null otherwise)
+// - errors : list of error messages collected during the flow
+// - unresolved : number of "🟠 needs review" rows still blocking the next step
+//
+// The hook exposes pure action dispatchers — it does NOT call services. The
+// page component is responsible for calling computeMigrationPlan,
+// createPreMigrationBackup and applyMigration, then dispatching the
+// corresponding actions here. Keeping the reducer service-free lets us test
+// the state transitions without mocking Tauri.
+// -----------------------------------------------------------------------------
+
+export type MigrationStep =
+ | "discover"
+ | "simulate"
+ | "consent"
+ | "running"
+ | "success"
+ | "error";
+
+export interface MigrationState {
+ step: MigrationStep;
+ plan: MigrationPlan | null;
+ backup: BackupResult | null;
+ outcome: MigrationOutcome | null;
+ /** Number of unresolved rows (confidence === 'none' && still missing v1TargetId). */
+ unresolved: number;
+ errors: string[];
+ /** Selected mapping row id to show in the TransactionPreviewPanel. Null = closed. */
+ selectedRowV2Id: number | null;
+}
+
+export type MigrationAction =
+ | { type: "LOAD_PLAN"; plan: MigrationPlan }
+ | { type: "RESOLVE_ROW"; v2CategoryId: number; v1TargetId: number; v1TargetName: string }
+ | { type: "SELECT_ROW"; v2CategoryId: number | null }
+ | { type: "GO_NEXT" }
+ | { type: "GO_BACK" }
+ | { type: "START_RUN" }
+ | { type: "SET_BACKUP"; backup: BackupResult }
+ | { type: "SET_OUTCOME"; outcome: MigrationOutcome }
+ | { type: "FAIL"; error: string }
+ | { type: "RESET" };
+
+export const INITIAL_STATE: MigrationState = {
+ step: "discover",
+ plan: null,
+ backup: null,
+ outcome: null,
+ unresolved: 0,
+ errors: [],
+ selectedRowV2Id: null,
+};
+
+function countUnresolved(rows: MappingRow[]): number {
+ let n = 0;
+ for (const r of rows) {
+ if (r.v1TargetId === null || r.v1TargetId === undefined) n++;
+ }
+ return n;
+}
+
+function nextStep(current: MigrationStep): MigrationStep {
+ switch (current) {
+ case "discover":
+ return "simulate";
+ case "simulate":
+ return "consent";
+ case "consent":
+ return "running";
+ case "running":
+ return "success";
+ default:
+ return current;
+ }
+}
+
+function previousStep(current: MigrationStep): MigrationStep {
+ switch (current) {
+ case "simulate":
+ return "discover";
+ case "consent":
+ return "simulate";
+ case "error":
+ return "consent";
+ default:
+ return current;
+ }
+}
+
+export function migrationReducer(
+ state: MigrationState,
+ action: MigrationAction,
+): MigrationState {
+ switch (action.type) {
+ case "LOAD_PLAN": {
+ return {
+ ...state,
+ plan: action.plan,
+ unresolved: countUnresolved(action.plan.rows),
+ errors: [],
+ };
+ }
+
+ case "RESOLVE_ROW": {
+ if (state.plan === null) return state;
+ const rows = state.plan.rows.map((r) =>
+ r.v2CategoryId === action.v2CategoryId
+ ? {
+ ...r,
+ v1TargetId: action.v1TargetId,
+ v1TargetName: action.v1TargetName,
+ // Once a user resolves a row manually, bump the confidence badge
+ // to "medium" so the simulate table reflects their decision.
+ // We keep the reason as-is so that the tooltip still explains
+ // what the algorithm thought.
+ confidence: r.confidence === "none" ? "medium" : r.confidence,
+ }
+ : r,
+ );
+ const plan: MigrationPlan = {
+ ...state.plan,
+ rows,
+ unresolved: rows.filter((r) => r.v1TargetId === null),
+ };
+ return {
+ ...state,
+ plan,
+ unresolved: countUnresolved(rows),
+ };
+ }
+
+ case "SELECT_ROW":
+ return { ...state, selectedRowV2Id: action.v2CategoryId };
+
+ case "GO_NEXT": {
+ // Guard: simulate -> consent requires unresolved === 0.
+ if (state.step === "simulate" && state.unresolved > 0) return state;
+ return { ...state, step: nextStep(state.step) };
+ }
+
+ case "GO_BACK":
+ return { ...state, step: previousStep(state.step) };
+
+ case "START_RUN":
+ return { ...state, step: "running", errors: [] };
+
+ case "SET_BACKUP":
+ return { ...state, backup: action.backup };
+
+ case "SET_OUTCOME": {
+ const nextStepName: MigrationStep = action.outcome.succeeded
+ ? "success"
+ : "error";
+ return {
+ ...state,
+ outcome: action.outcome,
+ step: nextStepName,
+ errors: action.outcome.error
+ ? [...state.errors, action.outcome.error]
+ : state.errors,
+ };
+ }
+
+ case "FAIL":
+ return {
+ ...state,
+ step: "error",
+ errors: [...state.errors, action.error],
+ };
+
+ case "RESET":
+ return INITIAL_STATE;
+
+ default:
+ return state;
+ }
+}
+
+export interface UseCategoryMigrationResult {
+ state: MigrationState;
+ loadPlan: (plan: MigrationPlan) => void;
+ resolveRow: (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => void;
+ selectRow: (v2CategoryId: number | null) => void;
+ goNext: () => void;
+ goBack: () => void;
+ startRun: () => void;
+ setBackup: (backup: BackupResult) => void;
+ setOutcome: (outcome: MigrationOutcome) => void;
+ fail: (error: string) => void;
+ reset: () => void;
+}
+
+export function useCategoryMigration(): UseCategoryMigrationResult {
+ const [state, dispatch] = useReducer(migrationReducer, INITIAL_STATE);
+
+ const loadPlan = useCallback((plan: MigrationPlan) => {
+ dispatch({ type: "LOAD_PLAN", plan });
+ }, []);
+
+ const resolveRow = useCallback(
+ (v2CategoryId: number, v1TargetId: number, v1TargetName: string) => {
+ dispatch({ type: "RESOLVE_ROW", v2CategoryId, v1TargetId, v1TargetName });
+ },
+ [],
+ );
+
+ const selectRow = useCallback((v2CategoryId: number | null) => {
+ dispatch({ type: "SELECT_ROW", v2CategoryId });
+ }, []);
+
+ const goNext = useCallback(() => dispatch({ type: "GO_NEXT" }), []);
+ const goBack = useCallback(() => dispatch({ type: "GO_BACK" }), []);
+ const startRun = useCallback(() => dispatch({ type: "START_RUN" }), []);
+
+ const setBackup = useCallback((backup: BackupResult) => {
+ dispatch({ type: "SET_BACKUP", backup });
+ }, []);
+
+ const setOutcome = useCallback((outcome: MigrationOutcome) => {
+ dispatch({ type: "SET_OUTCOME", outcome });
+ }, []);
+
+ const fail = useCallback((error: string) => {
+ dispatch({ type: "FAIL", error });
+ }, []);
+
+ const reset = useCallback(() => dispatch({ type: "RESET" }), []);
+
+ return {
+ state,
+ loadPlan,
+ resolveRow,
+ selectRow,
+ goNext,
+ goBack,
+ startRun,
+ setBackup,
+ setOutcome,
+ fail,
+ reset,
+ };
+}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index b621c77..8b82b2b 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -574,7 +574,9 @@
"title": "Category management",
"description": "Organize your expenses and income the way you want.",
"standardGuideTitle": "Standard category structure",
- "standardGuideDescription": "Browse the CPI taxonomy (read-only)"
+ "standardGuideDescription": "Browse the CPI taxonomy (read-only)",
+ "migrateTitle": "Migrate to the standard structure",
+ "migrateDescription": "Preview, back up and migrate in 3 steps"
},
"logs": {
"title": "Logs",
@@ -1273,6 +1275,143 @@
"subcategory": "Subcategory",
"leaf": "Leaf (final category)"
}
+ },
+ "migration": {
+ "customParent": "Custom categories (migration)",
+ "pageTitle": "Migrate to the standard structure",
+ "pageSubtitle": "Three steps: discover, simulate, consent. No data is changed until the final step.",
+ "backToSettings": "Back to settings",
+ "stepper": {
+ "ariaLabel": "Migration progress",
+ "discover": "Discover",
+ "simulate": "Simulate",
+ "consent": "Consent"
+ },
+ "alreadyMigrated": {
+ "title": "Your profile already uses the standard structure",
+ "body": "No migration needed. If you recently migrated, you can review the backup from Settings."
+ },
+ "discover": {
+ "title": "Step 1 · Discover the new structure",
+ "subtitle": "Take your time to explore the tree. You can come back later without risk.",
+ "intro": {
+ "title": "Why this structure?",
+ "body": "Statistics Canada's official household expenditure classification (CPI basket) identifies the main budget components of a Canadian household. It covers 100% of the financial flows of a typical Quebec household and produces clearer reports."
+ },
+ "next": "Continue to migration preview"
+ },
+ "simulate": {
+ "title": "Step 2 · Migration preview",
+ "subtitle": "Dry-run — no writes. Click a row to see affected transactions and, if needed, pick a different target.",
+ "loadError": "Failed to load profile data: {{error}}",
+ "needsReview": "Needs review",
+ "chooseTarget": "Choose a target...",
+ "txCount_one": "{{count}} transaction",
+ "txCount_other": "{{count}} transactions",
+ "unresolvedWarning_one": "You have {{count}} decision to make before you can continue.",
+ "unresolvedWarning_other": "You have {{count}} decisions to make before you can continue.",
+ "header": {
+ "current": "Current category",
+ "match": "Match",
+ "target": "Proposed v1 target"
+ },
+ "confidence": {
+ "high": "High",
+ "medium": "Medium",
+ "low": "Low",
+ "none": "Needs review"
+ },
+ "reason": {
+ "keyword": "Keyword-based",
+ "supplier": "Supplier-based",
+ "default": "Default mapping",
+ "review": "Manual review required",
+ "preserved": "Preserved"
+ },
+ "stats": {
+ "total": "Total"
+ },
+ "preserved": {
+ "title_one": "{{count}} custom category preserved",
+ "title_other": "{{count}} custom categories preserved",
+ "body": "Your custom categories will be grouped under the parent \"Custom categories (migration)\". You can move or rename them at your own pace after the migration.",
+ "txCount_one": "{{count}} transaction",
+ "txCount_other": "{{count}} transactions"
+ },
+ "panel": {
+ "title": "Affected transactions",
+ "subtitle_one": "{{count}} transaction attached to this category",
+ "subtitle_other": "{{count}} transactions attached to this category",
+ "close": "Close panel",
+ "noTransactions": "No transaction attached to this category.",
+ "overflow_one": "... and {{count}} more transaction",
+ "overflow_other": "... and {{count}} more transactions",
+ "willMapTo": "Will be reassigned to: {{target}}",
+ "noTarget": "No target chosen — a decision is required."
+ },
+ "back": "Back to discovery",
+ "next": "All decisions made · Continue"
+ },
+ "consent": {
+ "title": "Step 3 · Confirmation and backup",
+ "subtitle": "An encrypted copy of your profile will be created BEFORE any change. You can restore it at any time.",
+ "backup": {
+ "title": "Automatic backup before migration",
+ "body": "Simpl'Résultat creates a verified copy of your profile inside your Documents folder. The filename includes the date and time — nothing leaves your device.",
+ "location": "Location: ~/Documents/Simpl-Resultat/backups/"
+ },
+ "password": {
+ "label": "Profile PIN",
+ "help": "Your PIN is used to encrypt the backup (AES-256-GCM). It never leaves your device."
+ },
+ "checklist": {
+ "title": "Confirm that:",
+ "item1": "I understand this operation permanently modifies my categories.",
+ "item2": "I confirm a verified backup will be created before any change.",
+ "item3": "I can restore the backup at any time during 90 days."
+ },
+ "back": "Back",
+ "confirm": "Create backup and migrate"
+ },
+ "running": {
+ "title": "Migration in progress...",
+ "subtitle": "Do not close the application until the operation finishes.",
+ "step1": "Step 1 · Creating the backup",
+ "step2": "Step 2 · Verifying the backup (SHA-256 checksum)",
+ "step3": "Step 3 · Applying the migration (SQL transaction)",
+ "step4": "Step 4 · Committing and refreshing"
+ },
+ "success": {
+ "title": "Migration successful",
+ "subtitle": "Your profile now uses the standard structure (Statistics Canada CPI).",
+ "backupLabel": "Your backup is here:",
+ "restoreHint": "You can restore this backup at any time from Settings > Categories for 90 days.",
+ "stats": {
+ "inserted": "Categories added",
+ "transactions": "Transactions migrated",
+ "keywords": "Keywords migrated",
+ "budgets": "Budgets migrated"
+ },
+ "backToDashboard": "Go to dashboard",
+ "viewCategories": "View my categories"
+ },
+ "error": {
+ "title": "The migration could not be applied",
+ "subtitle": "No change was saved. Your profile is in the exact same state as before the operation.",
+ "rollbackNote": "If a partial backup was created, it stays available in your Documents folder — you can delete it manually if it is not useful.",
+ "retry": "Back to consent step",
+ "backToSettings": "Back to settings",
+ "backup": {
+ "missing_password": "Missing PIN — backing up a protected profile requires the PIN.",
+ "documents_dir_unavailable": "Unable to locate the Documents folder.",
+ "permission_denied": "Insufficient permissions to write in the backup folder.",
+ "disk_space": "Not enough disk space to create the backup.",
+ "create_dir_failed": "Unable to create the backup folder.",
+ "write_failed": "Failed to write the backup file.",
+ "read_back_failed": "Unable to read the backup file back for verification.",
+ "verification_mismatch": "Backup verification failed (invalid checksum or corrupted file)."
+ }
+ }
}
}
}
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index e2c9281..dd62007 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -574,7 +574,9 @@
"title": "Gestion des catégories",
"description": "Organisez vos dépenses et revenus selon vos besoins.",
"standardGuideTitle": "Structure standard des catégories",
- "standardGuideDescription": "Explorer la taxonomie IPC (lecture seule)"
+ "standardGuideDescription": "Explorer la taxonomie IPC (lecture seule)",
+ "migrateTitle": "Migrer vers la structure standard",
+ "migrateDescription": "Aperçu, sauvegarde et migration en 3 étapes"
},
"logs": {
"title": "Journaux",
@@ -1273,6 +1275,143 @@
"subcategory": "Sous-catégorie",
"leaf": "Feuille (catégorie finale)"
}
+ },
+ "migration": {
+ "customParent": "Catégories personnalisées (migration)",
+ "pageTitle": "Migrer vers la structure standard",
+ "pageSubtitle": "Trois étapes : découvrir, simuler, consentir. Aucune donnée n'est modifiée avant l'étape finale.",
+ "backToSettings": "Retour aux paramètres",
+ "stepper": {
+ "ariaLabel": "Progression de la migration",
+ "discover": "Découvrir",
+ "simulate": "Simuler",
+ "consent": "Consentir"
+ },
+ "alreadyMigrated": {
+ "title": "Votre profil utilise déjà la structure standard",
+ "body": "Aucune migration à effectuer. Si vous avez récemment migré, vous pouvez consulter la sauvegarde depuis les paramètres."
+ },
+ "discover": {
+ "title": "Étape 1 · Découvrir la nouvelle structure",
+ "subtitle": "Prenez le temps d'explorer l'arborescence. Vous pouvez revenir plus tard sans risque.",
+ "intro": {
+ "title": "Pourquoi cette structure ?",
+ "body": "La classification officielle des dépenses des ménages au Canada (Statistique Canada, panier IPC) identifie les grandes composantes du budget d'un ménage. Elle couvre 100 % des flux financiers d'un ménage québécois typique et permet des rapports plus lisibles."
+ },
+ "next": "Continuer vers l'aperçu de migration"
+ },
+ "simulate": {
+ "title": "Étape 2 · Aperçu de la migration",
+ "subtitle": "Simulation sans écriture. Cliquez sur une ligne pour voir les transactions impactées et, si besoin, choisir une autre cible.",
+ "loadError": "Impossible de charger les données du profil : {{error}}",
+ "needsReview": "À réviser",
+ "chooseTarget": "Choisir une cible...",
+ "txCount_one": "{{count}} transaction",
+ "txCount_other": "{{count}} transactions",
+ "unresolvedWarning_one": "Vous avez {{count}} décision à prendre avant de pouvoir continuer.",
+ "unresolvedWarning_other": "Vous avez {{count}} décisions à prendre avant de pouvoir continuer.",
+ "header": {
+ "current": "Catégorie actuelle",
+ "match": "Correspondance",
+ "target": "Cible v1 proposée"
+ },
+ "confidence": {
+ "high": "Haute",
+ "medium": "Moyenne",
+ "low": "Basse",
+ "none": "À réviser"
+ },
+ "reason": {
+ "keyword": "Basée sur un mot-clé",
+ "supplier": "Basée sur un fournisseur",
+ "default": "Mapping par défaut",
+ "review": "Révision manuelle requise",
+ "preserved": "Préservée"
+ },
+ "stats": {
+ "total": "Total"
+ },
+ "preserved": {
+ "title_one": "{{count}} catégorie personnalisée préservée",
+ "title_other": "{{count}} catégories personnalisées préservées",
+ "body": "Vos catégories personnalisées seront regroupées sous le parent « Catégories personnalisées (migration) ». Vous pourrez les déplacer ou les renommer à votre rythme après la migration.",
+ "txCount_one": "{{count}} transaction",
+ "txCount_other": "{{count}} transactions"
+ },
+ "panel": {
+ "title": "Transactions impactées",
+ "subtitle_one": "{{count}} transaction attachée à cette catégorie",
+ "subtitle_other": "{{count}} transactions attachées à cette catégorie",
+ "close": "Fermer le panneau",
+ "noTransactions": "Aucune transaction attachée à cette catégorie.",
+ "overflow_one": "... et {{count}} autre transaction",
+ "overflow_other": "... et {{count}} autres transactions",
+ "willMapTo": "Sera réassignée à : {{target}}",
+ "noTarget": "Aucune cible choisie — une décision est requise."
+ },
+ "back": "Revenir à la découverte",
+ "next": "Toutes décisions prises · Continuer"
+ },
+ "consent": {
+ "title": "Étape 3 · Confirmation et sauvegarde",
+ "subtitle": "Une copie chiffrée de votre profil sera créée AVANT tout changement. Vous pourrez la rétablir à tout moment.",
+ "backup": {
+ "title": "Sauvegarde automatique avant migration",
+ "body": "Simpl'Résultat crée une copie vérifiée de votre profil dans le dossier Documents. Le nom du fichier contient la date et l'heure — aucune donnée ne quitte votre appareil.",
+ "location": "Emplacement : ~/Documents/Simpl-Resultat/backups/"
+ },
+ "password": {
+ "label": "NIP du profil",
+ "help": "Votre NIP est utilisé pour chiffrer la sauvegarde (AES-256-GCM). Il ne quitte jamais votre appareil."
+ },
+ "checklist": {
+ "title": "Confirmez que :",
+ "item1": "J'ai compris que cette opération modifie définitivement mes catégories.",
+ "item2": "Je confirme qu'une sauvegarde vérifiée sera créée avant tout changement.",
+ "item3": "Je pourrai rétablir la sauvegarde à tout moment pendant 90 jours."
+ },
+ "back": "Revenir",
+ "confirm": "Créer la sauvegarde et migrer"
+ },
+ "running": {
+ "title": "Migration en cours...",
+ "subtitle": "Ne fermez pas l'application avant la fin de l'opération.",
+ "step1": "Étape 1 · Création de la sauvegarde",
+ "step2": "Étape 2 · Vérification de la sauvegarde (checksum SHA-256)",
+ "step3": "Étape 3 · Application de la migration (transaction SQL)",
+ "step4": "Étape 4 · Validation et rafraîchissement"
+ },
+ "success": {
+ "title": "Migration réussie",
+ "subtitle": "Votre profil utilise maintenant la structure standard (IPC Statistique Canada).",
+ "backupLabel": "Votre sauvegarde est ici :",
+ "restoreHint": "Vous pourrez rétablir cette sauvegarde à tout moment depuis Paramètres > Catégories pendant 90 jours.",
+ "stats": {
+ "inserted": "Catégories ajoutées",
+ "transactions": "Transactions migrées",
+ "keywords": "Mots-clés migrés",
+ "budgets": "Budgets migrés"
+ },
+ "backToDashboard": "Aller au tableau de bord",
+ "viewCategories": "Voir mes catégories"
+ },
+ "error": {
+ "title": "La migration n'a pas pu être appliquée",
+ "subtitle": "Aucun changement n'a été enregistré. Votre profil est dans l'état exact où il se trouvait avant l'opération.",
+ "rollbackNote": "Si une sauvegarde partielle a été créée, elle reste disponible dans votre dossier Documents — vous pouvez la supprimer manuellement si elle ne vous sert pas.",
+ "retry": "Revenir à l'étape de consentement",
+ "backToSettings": "Retour aux paramètres",
+ "backup": {
+ "missing_password": "NIP manquant — la sauvegarde d'un profil protégé nécessite le NIP.",
+ "documents_dir_unavailable": "Impossible de localiser le dossier Documents.",
+ "permission_denied": "Permissions insuffisantes pour écrire dans le dossier de sauvegarde.",
+ "disk_space": "Espace disque insuffisant pour créer la sauvegarde.",
+ "create_dir_failed": "Impossible de créer le dossier de sauvegarde.",
+ "write_failed": "Échec d'écriture du fichier de sauvegarde.",
+ "read_back_failed": "Impossible de relire le fichier de sauvegarde pour vérification.",
+ "verification_mismatch": "La vérification de la sauvegarde a échoué (checksum invalide ou fichier corrompu)."
+ }
+ }
}
}
}
diff --git a/src/pages/CategoriesMigrationPage.tsx b/src/pages/CategoriesMigrationPage.tsx
new file mode 100644
index 0000000..ea3727a
--- /dev/null
+++ b/src/pages/CategoriesMigrationPage.tsx
@@ -0,0 +1,538 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router-dom";
+import {
+ ArrowLeft,
+ CheckCircle2,
+ AlertOctagon,
+ FolderOpen,
+ RotateCcw,
+ Home as HomeIcon,
+} from "lucide-react";
+import { useProfile } from "../contexts/ProfileContext";
+import { useCategoryMigration } from "../hooks/useCategoryMigration";
+import {
+ computeMigrationPlan,
+ type ProfileData,
+} from "../services/categoryMappingService";
+import {
+ createPreMigrationBackup,
+ BackupError,
+} from "../services/categoryBackupService";
+import { applyMigration } from "../services/categoryMigrationService";
+import { getDb } from "../services/db";
+import { getPreference } from "../services/userPreferenceService";
+import StepDiscover from "../components/categories-migration/StepDiscover";
+import StepSimulate from "../components/categories-migration/StepSimulate";
+import StepConsent from "../components/categories-migration/StepConsent";
+
+/**
+ * 3-step category migration page — route `/settings/categories/migrate`.
+ *
+ * Wraps:
+ * - StepDiscover (read-only taxonomy preview, reused from #117)
+ * - StepSimulate (dry-run table + transaction preview side panel)
+ * - StepConsent (checklist + loader + success/error screens)
+ *
+ * The page owns the data-fetching pipeline:
+ * 1. Load profile data (categories, keywords, transactions, suppliers) from
+ * SQLite as soon as we enter step 2 the first time, then compute the
+ * MigrationPlan via the pure function from categoryMappingService.
+ * 2. On consent-confirm: createPreMigrationBackup → applyMigration → outcome.
+ *
+ * State transitions are delegated to useCategoryMigration (useReducer).
+ */
+
+const CATEGORIES_SCHEMA_VERSION_KEY = "categories_schema_version";
+
+type AlreadyMigratedState = "loading" | "needs_migration" | "already_v1";
+
+export default function CategoriesMigrationPage() {
+ const { t } = useTranslation();
+ const { activeProfile } = useProfile();
+ const {
+ state,
+ loadPlan,
+ resolveRow,
+ selectRow,
+ goNext,
+ goBack,
+ startRun,
+ setBackup,
+ setOutcome,
+ fail,
+ } = useCategoryMigration();
+
+ // Gate the page: profiles already on v1 should see a friendly "already done"
+ // screen instead of re-migrating (re-running the writer would be idempotent
+ // thanks to OR IGNORE, but it would surprise the user).
+ const [schemaCheck, setSchemaCheck] = useState
(
+ "loading",
+ );
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ const version = await getPreference(CATEGORIES_SCHEMA_VERSION_KEY);
+ if (cancelled) return;
+ setSchemaCheck(version === "v1" ? "already_v1" : "needs_migration");
+ } catch {
+ if (!cancelled) setSchemaCheck("needs_migration");
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ // Lazy-load the profile data + plan the first time we enter the simulate step.
+ const [txCountByV2Id, setTxCountByV2Id] = useState>(
+ () => new Map(),
+ );
+ const [loadError, setLoadError] = useState(null);
+ const planLoadedRef = useRef(false);
+
+ useEffect(() => {
+ if (state.step !== "simulate" || planLoadedRef.current) return;
+ planLoadedRef.current = true;
+
+ (async () => {
+ try {
+ const db = await getDb();
+
+ const [v2Cats, kws, txs, sups, txCounts] = await Promise.all([
+ db.select>(
+ "SELECT id, name, parent_id FROM categories WHERE is_active = 1",
+ ),
+ db.select>(
+ "SELECT id, keyword, category_id FROM keywords WHERE is_active = 1",
+ ),
+ db.select<
+ Array<{
+ id: number;
+ description: string;
+ category_id: number | null;
+ supplier_id: number | null;
+ }>
+ >(
+ "SELECT id, description, category_id, supplier_id FROM transactions",
+ ),
+ db.select>(
+ "SELECT id, name FROM suppliers WHERE is_active = 1",
+ ),
+ db.select>(
+ "SELECT category_id, COUNT(*) AS cnt FROM transactions WHERE category_id IS NOT NULL GROUP BY category_id",
+ ),
+ ]);
+
+ const profileData: ProfileData = {
+ v2Categories: v2Cats,
+ keywords: kws,
+ transactions: txs,
+ suppliers: sups,
+ };
+ const plan = computeMigrationPlan(profileData);
+ loadPlan(plan);
+
+ const counts = new Map();
+ for (const row of txCounts) {
+ counts.set(row.category_id, Number(row.cnt));
+ }
+ setTxCountByV2Id(counts);
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : String(e);
+ setLoadError(msg);
+ planLoadedRef.current = false;
+ }
+ })();
+ }, [state.step, loadPlan]);
+
+ // Password for PIN-protected backups.
+ const [password, setPassword] = useState("");
+ const requiresPassword =
+ !!activeProfile?.pin_hash && activeProfile.pin_hash.length > 0;
+
+ // Loader stage for the 4 sub-steps (see StepConsent RunningLoader).
+ const [runningStage, setRunningStage] = useState<0 | 1 | 2 | 3>(0);
+
+ const handleConfirm = async () => {
+ if (!activeProfile || !state.plan) return;
+ startRun();
+ setRunningStage(0);
+ try {
+ // 1. Create + verify the backup.
+ const backup = await createPreMigrationBackup({
+ profile: activeProfile,
+ password: requiresPassword ? password : undefined,
+ });
+ setBackup(backup);
+ setRunningStage(1);
+ // 2. Tiny pause for the UI to breathe (checksum already verified inside
+ // createPreMigrationBackup, but we bump the stage for the loader).
+ await new Promise((r) => setTimeout(r, 200));
+ setRunningStage(2);
+ // 3. Run the migration.
+ const outcome = await applyMigration(state.plan, backup);
+ setRunningStage(3);
+ await new Promise((r) => setTimeout(r, 200));
+ setOutcome(outcome);
+ } catch (e) {
+ if (e instanceof BackupError) {
+ const label = t(
+ `categoriesSeed.migration.error.backup.${e.code}`,
+ { defaultValue: e.message },
+ );
+ fail(label);
+ } else {
+ const msg = e instanceof Error ? e.message : String(e);
+ fail(msg);
+ }
+ }
+ };
+
+ // --------------------------------------------------------------------------
+ // Renders
+ // --------------------------------------------------------------------------
+
+ if (schemaCheck === "loading") {
+ return (
+
+ );
+ }
+
+ if (schemaCheck === "already_v1") {
+ return (
+
+
+
+ {t("categoriesSeed.migration.backToSettings")}
+
+
+
+ {t("categoriesSeed.migration.alreadyMigrated.title")}
+
+
+ {t("categoriesSeed.migration.alreadyMigrated.body")}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {t("categoriesSeed.migration.backToSettings")}
+
+
+
+
+
+
+
+ {loadError !== null && state.step === "simulate" && (
+
+ {t("categoriesSeed.migration.simulate.loadError", {
+ error: loadError,
+ })}
+
+ )}
+
+ {state.step === "discover" &&
}
+
+ {state.step === "simulate" && state.plan !== null && (
+
+ )}
+
+ {state.step === "simulate" && state.plan === null && loadError === null && (
+
+ )}
+
+ {(state.step === "consent" || state.step === "running") && (
+
+ )}
+
+ {state.step === "success" && state.outcome && (
+
+ )}
+
+ {state.step === "error" && (
+
+ )}
+
+ );
+}
+
+interface StepperProps {
+ step: string;
+}
+function Stepper({ step }: StepperProps) {
+ const { t } = useTranslation();
+ const steps: Array<{
+ key: string;
+ label: string;
+ active: boolean;
+ done: boolean;
+ }> = useMemo(
+ () => [
+ {
+ key: "discover",
+ label: t("categoriesSeed.migration.stepper.discover"),
+ active: step === "discover",
+ done: ["simulate", "consent", "running", "success"].includes(step),
+ },
+ {
+ key: "simulate",
+ label: t("categoriesSeed.migration.stepper.simulate"),
+ active: step === "simulate",
+ done: ["consent", "running", "success"].includes(step),
+ },
+ {
+ key: "consent",
+ label: t("categoriesSeed.migration.stepper.consent"),
+ active: step === "consent" || step === "running",
+ done: step === "success",
+ },
+ ],
+ [step, t],
+ );
+ return (
+
+ {steps.map((s, i) => (
+
+
+ {i + 1}
+
+
+ {s.label}
+
+ {i < steps.length - 1 && (
+
+ )}
+
+ ))}
+
+ );
+}
+
+interface SuccessScreenProps {
+ backupPath: string;
+ insertedV1: number;
+ updatedTx: number;
+ updatedKw: number;
+ updatedBg: number;
+}
+function SuccessScreen({
+ backupPath,
+ insertedV1,
+ updatedTx,
+ updatedKw,
+ updatedBg,
+}: SuccessScreenProps) {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+
+
+ {t("categoriesSeed.migration.success.title")}
+
+
+ {t("categoriesSeed.migration.success.subtitle")}
+
+
+
+
+
+
+
+ {t("categoriesSeed.migration.success.backupLabel")}
+
+
+ {backupPath}
+
+
+ {t("categoriesSeed.migration.success.restoreHint")}
+
+
+
+
+
+
+ {t("categoriesSeed.migration.success.stats.inserted")}
+
+ {insertedV1}
+
+
+
+ {t("categoriesSeed.migration.success.stats.transactions")}
+
+ {updatedTx}
+
+
+
+ {t("categoriesSeed.migration.success.stats.keywords")}
+
+ {updatedKw}
+
+
+
+ {t("categoriesSeed.migration.success.stats.budgets")}
+
+ {updatedBg}
+
+
+
+
+
+
+
+ {t("categoriesSeed.migration.success.backToDashboard")}
+
+
+ {t("categoriesSeed.migration.success.viewCategories")}
+
+
+
+ );
+}
+
+interface ErrorScreenProps {
+ errors: string[];
+ onRetry: () => void;
+}
+function ErrorScreen({ errors, onRetry }: ErrorScreenProps) {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+
+
+ {t("categoriesSeed.migration.error.title")}
+
+
+ {t("categoriesSeed.migration.error.subtitle")}
+
+
+
+ {errors.length > 0 && (
+
+ {errors.map((e, i) => (
+
+ {e}
+
+ ))}
+
+ )}
+
+ {t("categoriesSeed.migration.error.rollbackNote")}
+
+
+
+
+
+
+ {t("categoriesSeed.migration.error.retry")}
+
+
+ {t("categoriesSeed.migration.error.backToSettings")}
+
+
+
+ );
+}
diff --git a/src/services/categoryMigrationService.ts b/src/services/categoryMigrationService.ts
new file mode 100644
index 0000000..2d52bd1
--- /dev/null
+++ b/src/services/categoryMigrationService.ts
@@ -0,0 +1,401 @@
+import { getDb } from "./db";
+import { setPreference } from "./userPreferenceService";
+import { getTaxonomyV1, type TaxonomyNode } from "./categoryTaxonomyService";
+import type { BackupResult } from "./categoryBackupService";
+import type { MigrationPlan, MappingRow } from "./categoryMappingService";
+
+// -----------------------------------------------------------------------------
+// Category migration service — orchestrates the atomic v2 → v1 SQL writeover
+// using a MigrationPlan computed upstream by categoryMappingService.
+//
+// The service is intentionally destructive: it is only meant to be called
+// *after* a verified pre-migration SREF backup (BackupResult) has been written
+// to disk and confirmed by the user. The caller (CategoriesMigrationPage /
+// useCategoryMigration hook) is responsible for sequencing backup → migrate →
+// surface success/error — this service only runs the DB part.
+//
+// Ordering (all inside a single BEGIN / COMMIT transaction):
+// 1. Guard: backup looks valid (path + checksum present).
+// 2. BEGIN.
+// 3. If plan.preserved.length > 0: INSERT a new parent category
+// "Catégories personnalisées (migration)" (with i18n_key so it renders
+// in both languages), keep its new id.
+// 4. INSERT all v1 taxonomy nodes with explicit ids from the bundled
+// `categoryTaxonomyV1.json`. We skip ids that already exist in the DB
+// (OR IGNORE) so a re-run is safe.
+// 5. UPDATE transactions.category_id per plan.rows mapping (v2 → v1).
+// 6. UPDATE budget_entries.category_id and budget_template_entries.category_id
+// per plan.rows mapping (v2 → v1). We DELETE conflicting rows first
+// because budget_entries has UNIQUE(category_id, year, month) and
+// budget_template_entries has UNIQUE(template_id, category_id).
+// 7. UPDATE keywords.category_id per plan.rows mapping (v2 → v1). We
+// DELETE conflicting rows first because of UNIQUE(keyword, category_id).
+// 8. UPDATE suppliers.category_id per plan.rows mapping (v2 → v1).
+// 9. Re-parent preserved v2 custom categories under the new "Catégories
+// personnalisées (migration)" parent.
+// 10. DELETE v2 seeded categories that are now empty (no transactions /
+// keywords / budgets / suppliers / child categories referencing them).
+// Deletion is soft (is_active=0) to preserve ON DELETE CASCADE from
+// historical budget_entries etc. in edge cases.
+// 11. Set `categories_schema_version='v1'` and record
+// `last_categories_migration` JSON in user_preferences.
+// 12. COMMIT. On any thrown error: ROLLBACK and report in MigrationOutcome.
+// -----------------------------------------------------------------------------
+
+export interface MigrationOutcome {
+ /** True when the transaction committed; false if we rolled back or aborted. */
+ succeeded: boolean;
+ /** Human-readable error message on failure. Undefined on success. */
+ error?: string;
+ /** Number of v1 taxonomy rows we inserted (may be 0 on re-run). */
+ insertedV1Count: number;
+ /** Number of transactions whose category_id was rewritten. */
+ updatedTransactionsCount: number;
+ /** Number of budget_entries + budget_template_entries rows rewritten. */
+ updatedBudgetsCount: number;
+ /** Number of keywords rows rewritten. */
+ updatedKeywordsCount: number;
+ /** Number of v2 categories we deactivated (soft-delete). */
+ deletedV2Count: number;
+ /** Number of custom categories re-parented under the new parent. */
+ customPreservedCount: number;
+ /** Path to the SREF backup that was created before this run. */
+ backupPath: string;
+}
+
+/** JSON journalled in user_preferences.last_categories_migration. */
+export interface LastMigrationJournal {
+ timestamp: string;
+ backupPath: string;
+ outcome: Pick<
+ MigrationOutcome,
+ | "insertedV1Count"
+ | "updatedTransactionsCount"
+ | "updatedBudgetsCount"
+ | "updatedKeywordsCount"
+ | "deletedV2Count"
+ | "customPreservedCount"
+ >;
+}
+
+// Preference keys we write at the end of a successful migration.
+const SCHEMA_VERSION_KEY = "categories_schema_version";
+const LAST_MIGRATION_KEY = "last_categories_migration";
+
+// Id reserved for the "Catégories personnalisées (migration)" parent we
+// create when the profile has custom v2 categories. We deliberately pick a
+// number that is outside the v1 taxonomy range (1000 – 1999) and outside the
+// v2 seed range (< 1000) to avoid collisions even on re-runs.
+const CUSTOM_PARENT_NEW_ID = 2000;
+const CUSTOM_PARENT_NAME = "Catégories personnalisées (migration)";
+const CUSTOM_PARENT_I18N_KEY = "categoriesSeed.migration.customParent";
+const CUSTOM_PARENT_COLOR = "#64748b";
+const CUSTOM_PARENT_TYPE = "expense";
+
+// -----------------------------------------------------------------------------
+// Helpers
+// -----------------------------------------------------------------------------
+
+function flattenTaxonomy(root: TaxonomyNode, parentId: number | null, out: Array): void {
+ out.push({
+ id: root.id,
+ name: root.name,
+ i18n_key: root.i18n_key,
+ parent_id: parentId,
+ type: root.type,
+ color: root.color,
+ sort_order: root.sort_order,
+ is_inputable: root.children.length === 0,
+ });
+ for (const child of root.children) {
+ flattenTaxonomy(child, root.id, out);
+ }
+}
+
+interface TaxonomyFlat {
+ id: number;
+ name: string;
+ i18n_key: string;
+ parent_id: number | null;
+ type: string;
+ color: string;
+ sort_order: number;
+ is_inputable: boolean;
+}
+
+function listAllV1Rows(): TaxonomyFlat[] {
+ const flat: TaxonomyFlat[] = [];
+ for (const root of getTaxonomyV1().roots) {
+ flattenTaxonomy(root, null, flat);
+ }
+ return flat;
+}
+
+/** Validate the backup looks usable — path + checksum present. */
+function validateBackup(backup: BackupResult): void {
+ if (!backup || typeof backup.path !== "string" || backup.path.length === 0) {
+ throw new Error("invalid_backup: missing path");
+ }
+ if (typeof backup.checksum !== "string" || backup.checksum.length === 0) {
+ throw new Error("invalid_backup: missing checksum");
+ }
+}
+
+/** Build the v2Id → v1Id map from plan.rows (only resolved targets are kept). */
+function buildMappingFromRows(rows: MappingRow[]): Map {
+ const map = new Map();
+ for (const row of rows) {
+ if (row.v1TargetId !== null && row.v1TargetId !== undefined) {
+ map.set(row.v2CategoryId, row.v1TargetId);
+ }
+ }
+ return map;
+}
+
+// -----------------------------------------------------------------------------
+// Public entry point
+// -----------------------------------------------------------------------------
+
+export async function applyMigration(
+ plan: MigrationPlan,
+ backup: BackupResult,
+): Promise {
+ const outcome: MigrationOutcome = {
+ succeeded: false,
+ insertedV1Count: 0,
+ updatedTransactionsCount: 0,
+ updatedBudgetsCount: 0,
+ updatedKeywordsCount: 0,
+ deletedV2Count: 0,
+ customPreservedCount: 0,
+ backupPath: backup?.path ?? "",
+ };
+
+ try {
+ validateBackup(backup);
+ } catch (e) {
+ outcome.error = e instanceof Error ? e.message : String(e);
+ return outcome;
+ }
+
+ const db = await getDb();
+ const mapping = buildMappingFromRows(plan.rows);
+
+ await db.execute("BEGIN");
+ try {
+ // 1. Optionally create the "custom categories (migration)" parent.
+ let customParentId: number | null = null;
+ if (plan.preserved.length > 0) {
+ // Use INSERT OR IGNORE so a re-run never throws on the PK.
+ await db.execute(
+ `INSERT OR IGNORE INTO categories
+ (id, name, parent_id, color, type, is_active, is_inputable, sort_order, i18n_key)
+ VALUES ($1, $2, NULL, $3, $4, 1, 0, 99, $5)`,
+ [
+ CUSTOM_PARENT_NEW_ID,
+ CUSTOM_PARENT_NAME,
+ CUSTOM_PARENT_COLOR,
+ CUSTOM_PARENT_TYPE,
+ CUSTOM_PARENT_I18N_KEY,
+ ],
+ );
+ customParentId = CUSTOM_PARENT_NEW_ID;
+ }
+
+ // 2. INSERT v1 taxonomy. Roots first, then subcategories, then leaves,
+ // thanks to flattenTaxonomy's depth-first walk. Use OR IGNORE so a
+ // partial earlier run is recoverable.
+ const v1Rows = listAllV1Rows();
+ for (const row of v1Rows) {
+ const result = await db.execute(
+ `INSERT OR IGNORE INTO categories
+ (id, name, parent_id, color, type, is_active, is_inputable, sort_order, i18n_key)
+ VALUES ($1, $2, $3, $4, $5, 1, $6, $7, $8)`,
+ [
+ row.id,
+ row.name,
+ row.parent_id,
+ row.color,
+ row.type,
+ row.is_inputable ? 1 : 0,
+ row.sort_order,
+ row.i18n_key,
+ ],
+ );
+ // tauri-plugin-sql returns rowsAffected; on OR IGNORE conflicts it's 0.
+ const affected = Number(result.rowsAffected ?? 0);
+ outcome.insertedV1Count += affected;
+ }
+
+ // 3. Rewrite transactions.category_id v2 → v1.
+ for (const [v2Id, v1Id] of mapping.entries()) {
+ const r = await db.execute(
+ `UPDATE transactions SET category_id = $1, updated_at = CURRENT_TIMESTAMP
+ WHERE category_id = $2`,
+ [v1Id, v2Id],
+ );
+ outcome.updatedTransactionsCount += Number(r.rowsAffected ?? 0);
+ }
+
+ // 4. Rewrite budget_entries (handle UNIQUE(category_id, year, month) by
+ // deleting rows we'd collide with first — in a preview-and-consent
+ // flow, the collision means the user already has a budget on the v1
+ // target for the same period, so dropping the v2 duplicate is the
+ // least-surprising choice).
+ for (const [v2Id, v1Id] of mapping.entries()) {
+ await db.execute(
+ `DELETE FROM budget_entries
+ WHERE category_id = $1
+ AND (year, month) IN (
+ SELECT year, month FROM budget_entries WHERE category_id = $2
+ )`,
+ [v2Id, v1Id],
+ );
+ const r = await db.execute(
+ `UPDATE budget_entries SET category_id = $1, updated_at = CURRENT_TIMESTAMP
+ WHERE category_id = $2`,
+ [v1Id, v2Id],
+ );
+ outcome.updatedBudgetsCount += Number(r.rowsAffected ?? 0);
+ }
+
+ // 5. Rewrite budget_template_entries (same collision rule via
+ // UNIQUE(template_id, category_id)).
+ for (const [v2Id, v1Id] of mapping.entries()) {
+ await db.execute(
+ `DELETE FROM budget_template_entries
+ WHERE category_id = $1
+ AND template_id IN (
+ SELECT template_id FROM budget_template_entries WHERE category_id = $2
+ )`,
+ [v2Id, v1Id],
+ );
+ const r = await db.execute(
+ `UPDATE budget_template_entries SET category_id = $1
+ WHERE category_id = $2`,
+ [v1Id, v2Id],
+ );
+ outcome.updatedBudgetsCount += Number(r.rowsAffected ?? 0);
+ }
+
+ // 6. Rewrite keywords (UNIQUE(keyword, category_id)). Drop v2 keywords
+ // whose normalized spelling already points at the v1 target before the
+ // UPDATE, to avoid constraint violations.
+ for (const [v2Id, v1Id] of mapping.entries()) {
+ await db.execute(
+ `DELETE FROM keywords
+ WHERE category_id = $1
+ AND keyword IN (
+ SELECT keyword FROM keywords WHERE category_id = $2
+ )`,
+ [v2Id, v1Id],
+ );
+ const r = await db.execute(
+ `UPDATE keywords SET category_id = $1 WHERE category_id = $2`,
+ [v1Id, v2Id],
+ );
+ outcome.updatedKeywordsCount += Number(r.rowsAffected ?? 0);
+ }
+
+ // 7. Rewrite suppliers.category_id — no unique constraint, straightforward.
+ for (const [v2Id, v1Id] of mapping.entries()) {
+ await db.execute(
+ `UPDATE suppliers SET category_id = $1, updated_at = CURRENT_TIMESTAMP
+ WHERE category_id = $2`,
+ [v1Id, v2Id],
+ );
+ }
+
+ // 8. Re-parent preserved custom categories under the new parent. We touch
+ // only the top level of the custom tree (parent_id IS NULL or pointing
+ // at a v2 structural parent in the 1..6 range): children follow naturally.
+ if (customParentId !== null) {
+ for (const preservedRow of plan.preserved) {
+ const r = await db.execute(
+ `UPDATE categories SET parent_id = $1 WHERE id = $2`,
+ [customParentId, preservedRow.v2CategoryId],
+ );
+ outcome.customPreservedCount += Number(r.rowsAffected ?? 0);
+ }
+ }
+
+ // 9. Soft-delete v2 seeded categories that are now unreferenced.
+ // We deactivate instead of hard-deleting so that any historical
+ // reference we might have missed stays intact (is_active=0 hides them
+ // from the UI lists). We explicitly only target the v2 seed id range
+ // (< 1000) AND ids that map in our plan — this avoids touching user
+ // custom categories that may also have parent_id < 1000 structural.
+ for (const row of plan.rows) {
+ // Only deactivate rows that were part of the v2 seed AND we successfully
+ // mapped to a v1 target. Rows with no v1 target (unresolved review) are
+ // left alone — in the UX, the consent step is blocked until all rows
+ // are resolved, so this should be dead code, but it is a safety net.
+ if (row.v1TargetId === null) continue;
+ const r = await db.execute(
+ `UPDATE categories SET is_active = 0 WHERE id = $1`,
+ [row.v2CategoryId],
+ );
+ outcome.deletedV2Count += Number(r.rowsAffected ?? 0);
+ }
+
+ // 10. Also deactivate the v2 structural parents (1..6) — they have no v1
+ // equivalent and become obsolete after the migration.
+ {
+ const r = await db.execute(
+ `UPDATE categories SET is_active = 0
+ WHERE id IN (1, 2, 3, 4, 5, 6)`,
+ );
+ outcome.deletedV2Count += Number(r.rowsAffected ?? 0);
+ }
+
+ // 11. Bump the schema version and journal the run.
+ await db.execute(
+ `INSERT INTO user_preferences (key, value, updated_at)
+ VALUES ($1, 'v1', CURRENT_TIMESTAMP)
+ ON CONFLICT(key) DO UPDATE SET value = 'v1', updated_at = CURRENT_TIMESTAMP`,
+ [SCHEMA_VERSION_KEY],
+ );
+
+ const journal: LastMigrationJournal = {
+ timestamp: new Date().toISOString(),
+ backupPath: backup.path,
+ outcome: {
+ insertedV1Count: outcome.insertedV1Count,
+ updatedTransactionsCount: outcome.updatedTransactionsCount,
+ updatedBudgetsCount: outcome.updatedBudgetsCount,
+ updatedKeywordsCount: outcome.updatedKeywordsCount,
+ deletedV2Count: outcome.deletedV2Count,
+ customPreservedCount: outcome.customPreservedCount,
+ },
+ };
+ await db.execute(
+ `INSERT INTO user_preferences (key, value, updated_at)
+ VALUES ($1, $2, CURRENT_TIMESTAMP)
+ ON CONFLICT(key) DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP`,
+ [LAST_MIGRATION_KEY, JSON.stringify(journal)],
+ );
+
+ await db.execute("COMMIT");
+ outcome.succeeded = true;
+ return outcome;
+ } catch (e) {
+ try {
+ await db.execute("ROLLBACK");
+ } catch {
+ // Swallow: if the rollback itself fails there is nothing we can do here
+ // besides returning the original error to the caller.
+ }
+ outcome.error = e instanceof Error ? e.message : String(e);
+ outcome.succeeded = false;
+ return outcome;
+ }
+}
+
+/**
+ * Convenience: mark the schema as v1 without running the full migration.
+ * Exported for tests and tooling — the happy-path user flow goes through
+ * `applyMigration` which handles this transactionally.
+ */
+export async function markSchemaVersionV1(): Promise {
+ await setPreference(SCHEMA_VERSION_KEY, "v1");
+}