diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md
index efd6279..23b0d31 100644
--- a/CHANGELOG.fr.md
+++ b/CHANGELOG.fr.md
@@ -2,6 +2,10 @@
## [Non publié]
+### Ajouté
+
+- Migration des catégories : les catégories personnalisées sans correspondance standard peuvent désormais être **fusionnées** dans une catégorie standard, au lieu d'être seulement mises de côté. Chaque catégorie personnalisée du bloc « Catégories personnalisées » reçoit le même sélecteur de cible que les lignes du seed — choisissez une feuille standard et ses transactions, budgets, mots-clés et fournisseurs y sont réassignés, puis la catégorie personnalisée est retirée. Laisser une catégorie personnalisée non mappée conserve le comportement précédent (regroupée sous « Catégories personnalisées (migration) ») et ne bloque jamais la migration (#259).
+
## [0.14.0] - 2026-07-18
### Modifié
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0329f24..aab49ba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
## [Unreleased]
+### Added
+
+- Category migration: custom categories that have no standard match can now be **merged** into a standard category instead of only being set aside. Each custom category in the "Custom categories" block gets the same target picker as the seeded rows — choose a standard leaf and its transactions, budgets, keywords and suppliers are reassigned to it, then the custom category is removed. Leaving a custom category unmapped keeps the previous behaviour (grouped under "Custom categories (migration)") and never blocks the migration (#259).
+
## [0.14.0] - 2026-07-18
### Changed
diff --git a/src/components/categories-migration/StepSimulate.tsx b/src/components/categories-migration/StepSimulate.tsx
index e86c8d4..267f3a6 100644
--- a/src/components/categories-migration/StepSimulate.tsx
+++ b/src/components/categories-migration/StepSimulate.tsx
@@ -199,18 +199,20 @@ export default function StepSimulate({
-
+
{plan.preserved.map((row) => (
- -
- {row.v2CategoryName}
-
- {t("categoriesSeed.migration.simulate.preserved.txCount", {
- count: transactionCountByV2Id.get(row.v2CategoryId) ?? 0,
- })}
-
+
-
+
))}
diff --git a/src/hooks/useCategoryMigration.test.ts b/src/hooks/useCategoryMigration.test.ts
index 6129d6e..3849af8 100644
--- a/src/hooks/useCategoryMigration.test.ts
+++ b/src/hooks/useCategoryMigration.test.ts
@@ -95,6 +95,46 @@ describe("migrationReducer", () => {
expect(resolved.confidence).toBe("medium");
});
+ it("RESOLVE_ROW resolves a preserved custom category and bumps its confidence (#259)", () => {
+ const plan = makePlan([makeRow(10, 1011)], [makeRow(9001, null)]);
+ const s1 = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
+ const s2 = migrationReducer(s1, {
+ type: "RESOLVE_ROW",
+ v2CategoryId: 9001,
+ v1TargetId: 1111,
+ v1TargetName: "Épicerie régulière",
+ });
+ const merged = s2.plan!.preserved.find((r) => r.v2CategoryId === 9001)!;
+ expect(merged.v1TargetId).toBe(1111);
+ expect(merged.v1TargetName).toBe("Épicerie régulière");
+ expect(merged.confidence).toBe("medium");
+ });
+
+ it("RESOLVE_ROW on a preserved custom does NOT change unresolved (seed rows only) (#259)", () => {
+ // One unresolved seed row + one custom: the guard counts the seed only.
+ const plan = makePlan([makeRow(10, null)], [makeRow(9001, null)]);
+ const s1 = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
+ expect(s1.unresolved).toBe(1);
+ const s2 = migrationReducer(s1, {
+ type: "RESOLVE_ROW",
+ v2CategoryId: 9001,
+ v1TargetId: 1111,
+ v1TargetName: "Épicerie régulière",
+ });
+ // Merging the custom must not decrement the seed-row guard.
+ expect(s2.unresolved).toBe(1);
+ });
+
+ it("GO_NEXT advances simulate -> consent with an unmerged custom still present (#259)", () => {
+ // All seed rows resolved, one custom left unmerged: the wizard must proceed.
+ const plan = makePlan([makeRow(10, 1011)], [makeRow(9001, null)]);
+ let s = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
+ s = migrationReducer(s, { type: "GO_NEXT" }); // discover -> simulate
+ expect(s.step).toBe("simulate");
+ s = migrationReducer(s, { type: "GO_NEXT" }); // simulate -> consent
+ expect(s.step).toBe("consent");
+ });
+
it("GO_NEXT blocks simulate -> consent when unresolved > 0", () => {
const plan = makePlan([makeRow(10, null)]);
let s = migrationReducer(INITIAL_STATE, { type: "LOAD_PLAN", plan });
diff --git a/src/hooks/useCategoryMigration.ts b/src/hooks/useCategoryMigration.ts
index 5c9dc52..ac25072 100644
--- a/src/hooks/useCategoryMigration.ts
+++ b/src/hooks/useCategoryMigration.ts
@@ -118,28 +118,36 @@ export function migrationReducer(
case "RESOLVE_ROW": {
if (state.plan === null) return state;
- const rows = state.plan.rows.map((r) =>
+ // A resolved target can land on a seeded row (plan.rows) OR a custom
+ // category (plan.preserved) — the latter is the "merge a custom into a
+ // standard leaf" path (#259). Apply the target in whichever bucket holds
+ // the row; it is a no-op for the other one.
+ const applyTarget = (r: MappingRow): MappingRow =>
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.
+ // Resolving a row manually bumps the confidence badge to "medium"
+ // so the table reflects the decision. Reason is left as-is so the
+ // tooltip still explains what the algorithm thought.
confidence: r.confidence === "none" ? "medium" : r.confidence,
}
- : r,
- );
+ : r;
+ const rows = state.plan.rows.map(applyTarget);
+ const preserved = state.plan.preserved.map(applyTarget);
const plan: MigrationPlan = {
...state.plan,
rows,
+ preserved,
unresolved: rows.filter((r) => r.v1TargetId === null),
};
return {
...state,
plan,
+ // `unresolved` gates the Next button and counts seeded rows ONLY. A
+ // custom left unmerged is a legitimate choice and must never block the
+ // wizard.
unresolved: countUnresolved(rows),
};
}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 0dc580a..cc5cc61 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -1483,11 +1483,9 @@
"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"
+ "title_one": "{{count}} custom category",
+ "title_other": "{{count}} custom categories",
+ "body": "These categories have no standard equivalent. Pick a target to merge a category: its transactions, budgets, keywords and suppliers are reassigned to it and the category disappears. Leave the field empty to keep it as-is under \"Custom categories (migration)\"."
},
"panel": {
"title": "Affected transactions",
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index 2fa5417..fb7aa62 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -1483,11 +1483,9 @@
"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"
+ "title_one": "{{count}} catégorie personnalisée",
+ "title_other": "{{count}} catégories personnalisées",
+ "body": "Ces catégories n'ont pas d'équivalent standard. Choisissez une cible pour fusionner une catégorie : ses transactions, budgets, mots-clés et fournisseurs y sont réassignés et la catégorie disparaît. Laissez le champ vide pour la conserver telle quelle sous « Catégories personnalisées (migration) »."
},
"panel": {
"title": "Transactions impactées",
diff --git a/src/services/categoryMigrationService.test.ts b/src/services/categoryMigrationService.test.ts
index 9e20d58..f398bc5 100644
--- a/src/services/categoryMigrationService.test.ts
+++ b/src/services/categoryMigrationService.test.ts
@@ -303,6 +303,119 @@ describe("applyMigration — preserved custom categories", () => {
});
});
+// ---------------------------------------------------------------------------
+// Merged custom categories (#259)
+// ---------------------------------------------------------------------------
+
+describe("applyMigration — merged custom categories (#259)", () => {
+ it("reassigns transactions/budgets/keywords/suppliers of a merged custom to the chosen leaf", async () => {
+ // Custom category 9001 merged into leaf 1444.
+ await applyMigration(
+ makePlan([makeRow(22, 1111)], [makeRow(9001, 1444)]),
+ FAKE_BACKUP,
+ );
+ const merged = (re: RegExp) =>
+ fake.calls.filter(
+ (c) => re.test(c.sql) && (c.params?.[1] as number) === 9001,
+ );
+ // Each reassignment must target the chosen leaf (1444), keyed by the custom
+ // id (9001) — not merely be emitted.
+ expect(merged(/UPDATE transactions SET category_id/i)[0]?.params).toEqual([
+ 1444, 9001,
+ ]);
+ expect(merged(/UPDATE budget_entries SET category_id/i)[0]?.params).toEqual([
+ 1444, 9001,
+ ]);
+ expect(merged(/UPDATE keywords SET category_id/i)[0]?.params).toEqual([
+ 1444, 9001,
+ ]);
+ expect(merged(/UPDATE suppliers SET category_id/i)[0]?.params).toEqual([
+ 1444, 9001,
+ ]);
+ });
+
+ it("soft-deletes a merged custom (is_active=0) and does NOT re-parent it", async () => {
+ await applyMigration(
+ makePlan([makeRow(22, 1111)], [makeRow(9001, 1444)]),
+ FAKE_BACKUP,
+ );
+ const deactivate = fake.calls.filter(
+ (c) =>
+ /UPDATE categories SET is_active = 0 WHERE id = \$1/i.test(c.sql) &&
+ (c.params?.[0] as number) === 9001,
+ );
+ const reparent = fake.calls.filter(
+ (c) =>
+ /UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql) &&
+ (c.params?.[1] as number) === 9001,
+ );
+ expect(deactivate.length).toBe(1);
+ expect(reparent.length).toBe(0);
+ });
+
+ it("does NOT create the custom parent when every preserved custom is merged", async () => {
+ await applyMigration(
+ makePlan([makeRow(22, 1111)], [makeRow(9001, 1444), makeRow(9002, 1555)]),
+ FAKE_BACKUP,
+ );
+ const parentInserts = fake.calls.filter(
+ (c) =>
+ /INSERT OR IGNORE INTO categories/i.test(c.sql) &&
+ (c.params?.[0] as number) === 2000,
+ );
+ expect(parentInserts.length).toBe(0);
+ });
+
+ it("creates the custom parent when at least one preserved custom is left unmerged", async () => {
+ await applyMigration(
+ makePlan([makeRow(22, 1111)], [makeRow(9001, 1444), makeRow(9002, null)]),
+ FAKE_BACKUP,
+ );
+ const parentInserts = fake.calls.filter(
+ (c) =>
+ /INSERT OR IGNORE INTO categories/i.test(c.sql) &&
+ (c.params?.[0] as number) === 2000,
+ );
+ expect(parentInserts.length).toBe(1);
+ // Only the unmerged custom (9002) is re-parented; the merged one (9001) is not.
+ const reparent = fake.calls.filter((c) =>
+ /UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql),
+ );
+ expect(reparent.map((c) => c.params?.[1])).toEqual([9002]);
+ });
+
+ it("leaves no orphan when a custom PARENT is merged but its custom CHILD is not (regression)", async () => {
+ // plan.preserved is a flat list: a merged parent (9001 → 1444) and its
+ // unresolved child (9002). The child must land under the bucket (2000) and
+ // the parent be deactivated — the child never dangles under a dead parent.
+ await applyMigration(
+ makePlan([makeRow(22, 1111)], [makeRow(9001, 1444), makeRow(9002, null)]),
+ FAKE_BACKUP,
+ );
+ // Child re-parented under 2000.
+ const childReparent = fake.calls.filter(
+ (c) =>
+ /UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql) &&
+ (c.params?.[1] as number) === 9002,
+ );
+ expect(childReparent.length).toBe(1);
+ expect(childReparent[0].params?.[0]).toBe(2000);
+ // Parent merged → deactivated, not re-parented.
+ const parentDeactivate = fake.calls.filter(
+ (c) =>
+ /UPDATE categories SET is_active = 0 WHERE id = \$1/i.test(c.sql) &&
+ (c.params?.[0] as number) === 9001,
+ );
+ const parentReparent = fake.calls.filter(
+ (c) =>
+ /UPDATE categories SET parent_id = \$1 WHERE id = \$2/i.test(c.sql) &&
+ (c.params?.[1] as number) === 9001,
+ );
+ expect(parentDeactivate.length).toBe(1);
+ expect(parentReparent.length).toBe(0);
+ });
+});
+
// ---------------------------------------------------------------------------
// Rollback on SQL failure
// ---------------------------------------------------------------------------
diff --git a/src/services/categoryMigrationService.ts b/src/services/categoryMigrationService.ts
index 49e51f6..66be851 100644
--- a/src/services/categoryMigrationService.ts
+++ b/src/services/categoryMigrationService.ts
@@ -142,12 +142,17 @@ function validateBackup(backup: BackupResult): void {
}
}
-/** Build the v2Id → v1Id map from plan.rows (only resolved targets are kept). */
+/** True when a mapping row carries a concrete v1 target (resolved / merged). */
+function isResolvedTarget(row: MappingRow): boolean {
+ return row.v1TargetId !== null && row.v1TargetId !== undefined;
+}
+
+/** Build the v2Id → v1Id map from mapping rows (only resolved targets 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);
+ if (isResolvedTarget(row)) {
+ map.set(row.v2CategoryId, row.v1TargetId as number);
}
}
return map;
@@ -190,13 +195,21 @@ async function applyMigrationInTransaction(
backup: BackupResult,
outcome: MigrationOutcome,
): Promise {
- const mapping = buildMappingFromRows(plan.rows);
+ // Seeded rows AND merged custom categories (preserved rows the user gave a
+ // target) share the same rewrite: their transactions, budgets, keywords and
+ // suppliers are reassigned to the chosen v1 leaf. Unresolved preserved rows
+ // have a null target and are filtered out by buildMappingFromRows.
+ const allMappableRows = [...plan.rows, ...plan.preserved];
+ const mapping = buildMappingFromRows(allMappableRows);
await db.execute("BEGIN");
try {
- // 1. Optionally create the "custom categories (migration)" parent.
+ // 1. Optionally create the "custom categories (migration)" parent — only
+ // when at least one custom category is left UNMERGED. If every custom
+ // was merged into a standard leaf, the bucket would be empty, so skip it.
let customParentId: number | null = null;
- if (plan.preserved.length > 0) {
+ const hasUnmergedPreserved = plan.preserved.some((p) => !isResolvedTarget(p));
+ if (hasUnmergedPreserved) {
// Use INSERT OR IGNORE so a re-run never throws on the PK.
await db.execute(
`INSERT OR IGNORE INTO categories
@@ -322,6 +335,9 @@ async function applyMigrationInTransaction(
// at a v2 structural parent in the 1..6 range): children follow naturally.
if (customParentId !== null) {
for (const preservedRow of plan.preserved) {
+ // Merged customs are deactivated in the soft-delete step below, not
+ // re-parented — only the ones left unmerged move under the bucket.
+ if (isResolvedTarget(preservedRow)) continue;
const r = await db.execute(
`UPDATE categories SET parent_id = $1 WHERE id = $2`,
[customParentId, preservedRow.v2CategoryId],
@@ -330,18 +346,16 @@ async function applyMigrationInTransaction(
}
}
- // 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;
+ // 9. Soft-delete every category whose data we just rewrote: mapped v2 seed
+ // rows AND merged custom categories. We deactivate (is_active=0) instead
+ // of hard-deleting so any historical reference we might have missed stays
+ // intact and hidden from the UI lists.
+ for (const row of allMappableRows) {
+ // Skip rows with no target. Seed rows can't reach here (consent is
+ // blocked until they're all resolved), but a custom left unmerged
+ // legitimately does — it must stay alive under the bucket, not be
+ // deactivated.
+ if (!isResolvedTarget(row)) continue;
const r = await db.execute(
`UPDATE categories SET is_active = 0 WHERE id = $1`,
[row.v2CategoryId],