Livraison 1 du milestone spec-refonte-seed-categories-ipc. Applies the new v1 IPC (Indice des prix à la consommation) taxonomy to freshly created profiles while leaving existing v2 profiles untouched until the migration wizard (upcoming issue #121) prompts them to move. - Migration v8 (additive only): - ALTER TABLE categories ADD COLUMN i18n_key TEXT - INSERT OR IGNORE user_preferences.categories_schema_version=v2 (existing profiles tagged as v2 for later migration) - consolidated_schema.sql rewritten with the full v1 seed and categories_schema_version='v1' default for brand-new profiles - src/data/categoryTaxonomyV1.json bundled as the TS-side source of truth (consumed by #116 categoryTaxonomyService next) - categoriesSeed.* i18n namespace (FR/EN) — 150 entries each - CategoryTree and CategoryCombobox fall back to the raw `name` when i18n_key is null (user-created categories stay literal) - CategoryTreeNode and CategoryRow gain the i18n_key field end-to-end Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
408 lines
14 KiB
TypeScript
408 lines
14 KiB
TypeScript
import { getDb } from "./db";
|
|
import type { Keyword } from "../shared/types";
|
|
|
|
interface CategoryRow {
|
|
id: number;
|
|
name: string;
|
|
parent_id: number | null;
|
|
color: string | null;
|
|
icon: string | null;
|
|
type: "expense" | "income" | "transfer";
|
|
is_active: boolean;
|
|
is_inputable: boolean;
|
|
sort_order: number;
|
|
keyword_count: number;
|
|
i18n_key: string | null;
|
|
}
|
|
|
|
export async function getAllCategoriesWithCounts(): Promise<CategoryRow[]> {
|
|
const db = await getDb();
|
|
return db.select<CategoryRow[]>(
|
|
`SELECT c.*, COUNT(k.id) AS keyword_count
|
|
FROM categories c
|
|
LEFT JOIN keywords k ON k.category_id = c.id AND k.is_active = 1
|
|
WHERE c.is_active = 1
|
|
GROUP BY c.id
|
|
ORDER BY c.sort_order, c.name`
|
|
);
|
|
}
|
|
|
|
export async function getCategoryDepth(categoryId: number): Promise<number> {
|
|
const db = await getDb();
|
|
let depth = 0;
|
|
let currentId: number | null = categoryId;
|
|
while (currentId !== null) {
|
|
const parentRows: Array<{ parent_id: number | null }> = await db.select<Array<{ parent_id: number | null }>>(
|
|
`SELECT parent_id FROM categories WHERE id = $1 AND is_active = 1`,
|
|
[currentId]
|
|
);
|
|
if (parentRows.length === 0 || parentRows[0].parent_id === null) break;
|
|
currentId = parentRows[0].parent_id;
|
|
depth++;
|
|
}
|
|
return depth;
|
|
}
|
|
|
|
export async function createCategory(data: {
|
|
name: string;
|
|
type: string;
|
|
color: string;
|
|
parent_id: number | null;
|
|
is_inputable: boolean;
|
|
sort_order: number;
|
|
}): Promise<number> {
|
|
const db = await getDb();
|
|
|
|
// Validate max depth: parent at depth 2 would create a 4th level
|
|
if (data.parent_id !== null) {
|
|
const parentDepth = await getCategoryDepth(data.parent_id);
|
|
if (parentDepth >= 2) {
|
|
throw new Error("Cannot create category: maximum depth of 3 levels reached");
|
|
}
|
|
}
|
|
|
|
const result = await db.execute(
|
|
`INSERT INTO categories (name, type, color, parent_id, is_inputable, sort_order) VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
[data.name, data.type, data.color, data.parent_id, data.is_inputable ? 1 : 0, data.sort_order]
|
|
);
|
|
|
|
// Auto-manage is_inputable: when a child is created under a parent, set parent to is_inputable = 0
|
|
if (data.parent_id !== null) {
|
|
await db.execute(
|
|
`UPDATE categories SET is_inputable = 0 WHERE id = $1 AND is_inputable = 1`,
|
|
[data.parent_id]
|
|
);
|
|
}
|
|
|
|
return result.lastInsertId as number;
|
|
}
|
|
|
|
export async function updateCategory(
|
|
id: number,
|
|
data: {
|
|
name: string;
|
|
type: string;
|
|
color: string;
|
|
parent_id: number | null;
|
|
is_inputable: boolean;
|
|
sort_order: number;
|
|
}
|
|
): Promise<void> {
|
|
const db = await getDb();
|
|
await db.execute(
|
|
`UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, is_inputable = $5, sort_order = $6 WHERE id = $7`,
|
|
[data.name, data.type, data.color, data.parent_id, data.is_inputable ? 1 : 0, data.sort_order, id]
|
|
);
|
|
}
|
|
|
|
export async function getNextSortOrder(parentId: number | null): Promise<number> {
|
|
const db = await getDb();
|
|
const rows = parentId === null
|
|
? await db.select<Array<{ max_sort: number | null }>>(
|
|
`SELECT MAX(sort_order) AS max_sort FROM categories WHERE is_active = 1 AND parent_id IS NULL`
|
|
)
|
|
: await db.select<Array<{ max_sort: number | null }>>(
|
|
`SELECT MAX(sort_order) AS max_sort FROM categories WHERE is_active = 1 AND parent_id = $1`,
|
|
[parentId]
|
|
);
|
|
return (rows[0]?.max_sort ?? 0) + 1;
|
|
}
|
|
|
|
export async function hasDuplicateSortOrders(): Promise<boolean> {
|
|
const db = await getDb();
|
|
const rows = await db.select<Array<{ cnt: number }>>(
|
|
`SELECT COUNT(*) AS cnt FROM (
|
|
SELECT parent_id, sort_order FROM categories WHERE is_active = 1
|
|
GROUP BY parent_id, sort_order HAVING COUNT(*) > 1
|
|
)`
|
|
);
|
|
return (rows[0]?.cnt ?? 0) > 0;
|
|
}
|
|
|
|
export async function fixDuplicateSortOrders(): Promise<void> {
|
|
const db = await getDb();
|
|
const rows = await db.select<Array<{ id: number; parent_id: number | null }>>(
|
|
`SELECT id, parent_id FROM categories WHERE is_active = 1 ORDER BY parent_id, sort_order, name`
|
|
);
|
|
|
|
let currentParentId: number | null | undefined = undefined;
|
|
let seq = 0;
|
|
for (const row of rows) {
|
|
if (row.parent_id !== currentParentId) {
|
|
currentParentId = row.parent_id;
|
|
seq = 0;
|
|
}
|
|
seq++;
|
|
await db.execute(
|
|
`UPDATE categories SET sort_order = $1 WHERE id = $2`,
|
|
[seq, row.id]
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function updateCategorySortOrders(
|
|
updates: Array<{ id: number; sort_order: number; parent_id: number | null }>
|
|
): Promise<void> {
|
|
const db = await getDb();
|
|
for (const u of updates) {
|
|
await db.execute(
|
|
`UPDATE categories SET sort_order = $1, parent_id = $2 WHERE id = $3`,
|
|
[u.sort_order, u.parent_id, u.id]
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function deactivateCategory(id: number): Promise<void> {
|
|
const db = await getDb();
|
|
// Remember the parent before deactivating
|
|
const rows = await db.select<Array<{ parent_id: number | null }>>(
|
|
`SELECT parent_id FROM categories WHERE id = $1`,
|
|
[id]
|
|
);
|
|
const parentId = rows[0]?.parent_id ?? null;
|
|
|
|
// Promote children to parent level so they don't become orphans
|
|
await db.execute(
|
|
`UPDATE categories SET parent_id = $1 WHERE parent_id = $2`,
|
|
[parentId, id]
|
|
);
|
|
// Only deactivate the target category itself
|
|
await db.execute(
|
|
`UPDATE categories SET is_active = 0 WHERE id = $1`,
|
|
[id]
|
|
);
|
|
|
|
// Auto-manage is_inputable: if parent now has no active children, restore is_inputable
|
|
if (parentId !== null) {
|
|
const childCount = await db.select<Array<{ cnt: number }>>(
|
|
`SELECT COUNT(*) AS cnt FROM categories WHERE parent_id = $1 AND is_active = 1`,
|
|
[parentId]
|
|
);
|
|
if ((childCount[0]?.cnt ?? 0) === 0) {
|
|
await db.execute(
|
|
`UPDATE categories SET is_inputable = 1 WHERE id = $1`,
|
|
[parentId]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function getCategoryUsageCount(id: number): Promise<number> {
|
|
const db = await getDb();
|
|
const rows = await db.select<Array<{ cnt: number }>>(
|
|
`SELECT COUNT(*) AS cnt FROM transactions WHERE category_id = $1`,
|
|
[id]
|
|
);
|
|
return rows[0]?.cnt ?? 0;
|
|
}
|
|
|
|
export async function getChildrenUsageCount(parentId: number): Promise<number> {
|
|
const db = await getDb();
|
|
// Check descendants recursively (up to 2 levels deep)
|
|
const rows = await db.select<Array<{ cnt: number }>>(
|
|
`SELECT COUNT(*) AS cnt FROM transactions WHERE category_id IN (
|
|
SELECT id FROM categories WHERE parent_id = $1 AND is_active = 1
|
|
UNION
|
|
SELECT id FROM categories WHERE parent_id IN (
|
|
SELECT id FROM categories WHERE parent_id = $1 AND is_active = 1
|
|
) AND is_active = 1
|
|
)`,
|
|
[parentId]
|
|
);
|
|
return rows[0]?.cnt ?? 0;
|
|
}
|
|
|
|
export async function reinitializeCategories(): Promise<void> {
|
|
const db = await getDb();
|
|
// Clear keywords, unlink transactions, delete all categories
|
|
await db.execute("DELETE FROM keywords");
|
|
await db.execute("UPDATE transactions SET category_id = NULL");
|
|
await db.execute("DELETE FROM categories");
|
|
|
|
// Re-seed parent categories
|
|
const parents = [
|
|
[1, "Revenus", "income", 1],
|
|
[2, "Dépenses récurrentes", "expense", 2],
|
|
[3, "Dépenses ponctuelles", "expense", 3],
|
|
[4, "Maison", "expense", 4],
|
|
[5, "Placements", "transfer", 5],
|
|
[6, "Autres", "expense", 6],
|
|
] as const;
|
|
for (const [id, name, type, sort] of parents) {
|
|
await db.execute(
|
|
"INSERT INTO categories (id, name, type, sort_order) VALUES ($1, $2, $3, $4)",
|
|
[id, name, type, sort]
|
|
);
|
|
}
|
|
|
|
// Re-seed child categories (level 2)
|
|
// Note: Assurances (31) is now a non-inputable intermediate parent with level-3 children
|
|
const children: Array<[number, string, number, string, string, number, boolean]> = [
|
|
[10, "Paie", 1, "income", "#22c55e", 1, true],
|
|
[11, "Autres revenus", 1, "income", "#4ade80", 2, true],
|
|
[20, "Loyer", 2, "expense", "#ef4444", 1, true],
|
|
[21, "Électricité", 2, "expense", "#f59e0b", 2, true],
|
|
[22, "Épicerie", 2, "expense", "#10b981", 3, true],
|
|
[23, "Dons", 2, "expense", "#ec4899", 4, true],
|
|
[24, "Restaurant", 2, "expense", "#f97316", 5, true],
|
|
[25, "Frais bancaires", 2, "expense", "#6b7280", 6, true],
|
|
[26, "Jeux, Films & Livres", 2, "expense", "#8b5cf6", 7, true],
|
|
[27, "Abonnements Musique", 2, "expense", "#06b6d4", 8, true],
|
|
[28, "Transport en commun", 2, "expense", "#3b82f6", 9, true],
|
|
[29, "Internet & Télécom", 2, "expense", "#6366f1", 10, true],
|
|
[30, "Animaux", 2, "expense", "#a855f7", 11, true],
|
|
[31, "Assurances", 2, "expense", "#14b8a6", 12, false], // intermediate parent
|
|
[32, "Pharmacie", 2, "expense", "#f43f5e", 13, true],
|
|
[33, "Taxes municipales", 2, "expense", "#78716c", 14, true],
|
|
[40, "Voiture", 3, "expense", "#64748b", 1, true],
|
|
[41, "Amazon", 3, "expense", "#f59e0b", 2, true],
|
|
[42, "Électroniques", 3, "expense", "#3b82f6", 3, true],
|
|
[43, "Alcool", 3, "expense", "#7c3aed", 4, true],
|
|
[44, "Cadeaux", 3, "expense", "#ec4899", 5, true],
|
|
[45, "Vêtements", 3, "expense", "#d946ef", 6, true],
|
|
[46, "CPA", 3, "expense", "#0ea5e9", 7, true],
|
|
[47, "Voyage", 3, "expense", "#f97316", 8, true],
|
|
[48, "Sports & Plein air", 3, "expense", "#22c55e", 9, true],
|
|
[49, "Spectacles & sorties", 3, "expense", "#e11d48", 10, true],
|
|
[50, "Hypothèque", 4, "expense", "#dc2626", 1, true],
|
|
[51, "Achats maison", 4, "expense", "#ea580c", 2, true],
|
|
[52, "Entretien maison", 4, "expense", "#ca8a04", 3, true],
|
|
[53, "Électroménagers & Meubles", 4, "expense", "#0d9488", 4, true],
|
|
[54, "Outils", 4, "expense", "#b45309", 5, true],
|
|
[60, "Placements", 5, "transfer", "#2563eb", 1, true],
|
|
[61, "Transferts", 5, "transfer", "#7c3aed", 2, true],
|
|
[70, "Impôts", 6, "expense", "#dc2626", 1, true],
|
|
[71, "Paiement CC", 6, "transfer", "#6b7280", 2, true],
|
|
[72, "Retrait cash", 6, "expense", "#57534e", 3, true],
|
|
[73, "Projets", 6, "expense", "#0ea5e9", 4, true],
|
|
];
|
|
for (const [id, name, parentId, type, color, sort, inputable] of children) {
|
|
await db.execute(
|
|
"INSERT INTO categories (id, name, parent_id, type, color, sort_order, is_inputable) VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
|
[id, name, parentId, type, color, sort, inputable ? 1 : 0]
|
|
);
|
|
}
|
|
|
|
// Re-seed grandchild categories (level 3) — under Assurances (31)
|
|
const grandchildren: Array<[number, string, number, string, string, number]> = [
|
|
[310, "Assurance-auto", 31, "expense", "#14b8a6", 1],
|
|
[311, "Assurance-habitation", 31, "expense", "#0d9488", 2],
|
|
[312, "Assurance-vie", 31, "expense", "#0f766e", 3],
|
|
];
|
|
for (const [id, name, parentId, type, color, sort] of grandchildren) {
|
|
await db.execute(
|
|
"INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES ($1, $2, $3, $4, $5, $6)",
|
|
[id, name, parentId, type, color, sort]
|
|
);
|
|
}
|
|
|
|
// Re-seed keywords
|
|
const keywords: Array<[string, number]> = [
|
|
["PAY/PAY", 10],
|
|
["HYDRO-QUEBEC", 21],
|
|
["METRO", 22], ["IGA", 22], ["MAXI", 22], ["SUPER C", 22],
|
|
["BOUCHERIE LAFLECHE", 22], ["BOULANGERIE JARRY", 22], ["DOLLARAMA", 22], ["WALMART", 22],
|
|
["OXFAM", 23], ["CENTRAIDE", 23], ["FPA", 23],
|
|
["SUBWAY", 24], ["MCDONALD", 24], ["A&W", 24], ["DD/DOORDASH", 24],
|
|
["DOORDASH", 24], ["SUSHI", 24], ["DOMINOS", 24], ["BELLE PROVINCE", 24],
|
|
["PROGRAMME PERFORMANCE", 25],
|
|
["STEAMGAMES", 26], ["PLAYSTATION", 26], ["PRIMEVIDEO", 26], ["NINTENDO", 26],
|
|
["RENAUD-BRAY", 26], ["CINEMA DU PARC", 26], ["LEGO", 26],
|
|
["SPOTIFY", 27],
|
|
["STM", 28], ["GARE MONT-SAINT", 28], ["GARE SAINT-HUBERT", 28],
|
|
["GARE CENTRALE", 28], ["REM", 28],
|
|
["VIDEOTRON", 29], ["ORICOM", 29],
|
|
["MONDOU", 30],
|
|
["BELAIR", 310], ["PRYSM", 311], ["INS/ASS", 312],
|
|
["JEAN COUTU", 32], ["FAMILIPRIX", 32], ["PHARMAPRIX", 32],
|
|
["M-ST-HILAIRE TX", 33], ["CSS PATRIOT", 33],
|
|
["SHELL", 40], ["ESSO", 40], ["ULTRAMAR", 40], ["PETRO-CANADA", 40],
|
|
["SAAQ", 40], ["CREVIER", 40],
|
|
["AMAZON", 41], ["AMZN", 41],
|
|
["MICROSOFT", 42], ["ADDISON ELECTRONIQUE", 42],
|
|
["SAQ", 43], ["SQDC", 43],
|
|
["DANS UN JARDIN", 44],
|
|
["UNIQLO", 45], ["WINNERS", 45], ["SIMONS", 45],
|
|
["ORDRE DES COMPTABL", 46],
|
|
["NORWEGIAN CRUISE", 47], ["AEROPORTS DE MONTREAL", 47], ["HILTON", 47],
|
|
["BLOC SHOP", 48], ["SEPAQ", 48], ["LA CORDEE", 48],
|
|
["MOUNTAIN EQUIPMENT", 48], ["PHYSIOACTIF", 48], ["DECATHLON", 48],
|
|
["TICKETMASTER", 49], ["CLUB SODA", 49], ["LEPOINTDEVENTE", 49],
|
|
["MTG/HYP", 50],
|
|
["CANADIAN TIRE", 51], ["CANAC", 51], ["RONA", 51],
|
|
["IKEA", 52],
|
|
["TANGUAY", 53], ["BOUCLAIR", 53],
|
|
["BMR", 54], ["HOME DEPOT", 54], ["PRINCESS AUTO", 54],
|
|
["DYNAMIC FUND", 60], ["FIDELITY", 60], ["AGF", 60],
|
|
["WS INVESTMENTS", 61], ["PEAK INVESTMENT", 61],
|
|
["GOUV. QUEBEC", 70],
|
|
["CLAUDE.AI", 73], ["NAME-CHEAP", 73],
|
|
];
|
|
for (const [kw, catId] of keywords) {
|
|
await db.execute(
|
|
"INSERT INTO keywords (keyword, category_id) VALUES ($1, $2)",
|
|
[kw, catId]
|
|
);
|
|
}
|
|
}
|
|
|
|
export interface KeywordWithCategory {
|
|
id: number;
|
|
keyword: string;
|
|
priority: number;
|
|
category_id: number;
|
|
category_name: string;
|
|
category_color: string;
|
|
}
|
|
|
|
export async function getAllKeywordsWithCategory(): Promise<KeywordWithCategory[]> {
|
|
const db = await getDb();
|
|
return db.select<KeywordWithCategory[]>(
|
|
`SELECT k.id, k.keyword, k.priority, k.category_id,
|
|
c.name AS category_name, c.color AS category_color
|
|
FROM keywords k
|
|
JOIN categories c ON k.category_id = c.id AND c.is_active = 1
|
|
WHERE k.is_active = 1
|
|
ORDER BY k.keyword COLLATE NOCASE`
|
|
);
|
|
}
|
|
|
|
export async function getKeywordsByCategoryId(
|
|
categoryId: number
|
|
): Promise<Keyword[]> {
|
|
const db = await getDb();
|
|
return db.select<Keyword[]>(
|
|
`SELECT * FROM keywords WHERE category_id = $1 AND is_active = 1 ORDER BY priority DESC, keyword`,
|
|
[categoryId]
|
|
);
|
|
}
|
|
|
|
export async function createKeyword(
|
|
categoryId: number,
|
|
keyword: string,
|
|
priority: number
|
|
): Promise<number> {
|
|
const db = await getDb();
|
|
const result = await db.execute(
|
|
`INSERT INTO keywords (keyword, category_id, priority) VALUES ($1, $2, $3)`,
|
|
[keyword, categoryId, priority]
|
|
);
|
|
return result.lastInsertId as number;
|
|
}
|
|
|
|
export async function updateKeyword(
|
|
id: number,
|
|
keyword: string,
|
|
priority: number
|
|
): Promise<void> {
|
|
const db = await getDb();
|
|
await db.execute(
|
|
`UPDATE keywords SET keyword = $1, priority = $2 WHERE id = $3`,
|
|
[keyword, priority, id]
|
|
);
|
|
}
|
|
|
|
export async function deactivateKeyword(id: number): Promise<void> {
|
|
const db = await getDb();
|
|
await db.execute(`UPDATE keywords SET is_active = 0 WHERE id = $1`, [id]);
|
|
}
|