From 7bd19709a9060ad3d24681979a5bfb2caa898a96 Mon Sep 17 00:00:00 2001 From: Jeremy Fievet Date: Tue, 2 Jun 2026 23:08:40 +0200 Subject: [PATCH] feat: add transaction type detection --- .../migrations/014_transaction_types.sql | 31 ++++++++++++ src-tauri/src/commands.rs | 47 ++++++++++++++++++- src-tauri/src/db.rs | 6 +++ src/lib/stores/transactions.svelte.ts | 41 ++++++++++++---- src/lib/types/index.ts | 13 +++++ src/lib/utils/format.test.ts | 21 ++++++++- src/lib/utils/format.ts | 27 +++++++++++ src/routes/accounts/+page.svelte | 4 +- src/routes/transactions/+page.svelte | 23 ++++++++- 9 files changed, 197 insertions(+), 16 deletions(-) create mode 100644 src-tauri/migrations/014_transaction_types.sql diff --git a/src-tauri/migrations/014_transaction_types.sql b/src-tauri/migrations/014_transaction_types.sql new file mode 100644 index 0000000..dfad563 --- /dev/null +++ b/src-tauri/migrations/014_transaction_types.sql @@ -0,0 +1,31 @@ +-- Transaction type classification +ALTER TABLE transactions ADD COLUMN transaction_type TEXT DEFAULT 'other' + CHECK(transaction_type IN ( + 'card', + 'transfer', + 'direct_debit', + 'check', + 'cash_withdrawal', + 'cash_deposit', + 'fee', + 'refund', + 'income', + 'other' + )); + +UPDATE transactions +SET transaction_type = CASE + WHEN LOWER(label) LIKE '%cheque%' OR LOWER(label) LIKE '%chèque%' OR LOWER(label) LIKE '%chq%' THEN 'check' + WHEN LOWER(label) LIKE '%retrait%' OR LOWER(label) LIKE '%dab%' OR LOWER(label) LIKE '%gab%' THEN 'cash_withdrawal' + WHEN LOWER(label) LIKE '%depot esp%' OR LOWER(label) LIKE '%dépôt esp%' OR LOWER(label) LIKE '%remise esp%' OR LOWER(label) LIKE '%versement esp%' THEN 'cash_deposit' + WHEN LOWER(label) LIKE '%prlv%' OR LOWER(label) LIKE '%prelevement%' OR LOWER(label) LIKE '%prélèvement%' THEN 'direct_debit' + WHEN LOWER(label) LIKE '%vir%' OR LOWER(label) LIKE '%virement%' OR LOWER(label) LIKE '%transfert%' THEN 'transfer' + WHEN LOWER(label) LIKE '%cb%' OR LOWER(label) LIKE '%carte%' THEN 'card' + WHEN LOWER(label) LIKE '%frais%' OR LOWER(label) LIKE '%commission%' THEN 'fee' + WHEN LOWER(label) LIKE '%remb%' OR LOWER(label) LIKE '%refund%' THEN 'refund' + WHEN amount > 0 THEN 'income' + ELSE 'other' +END +WHERE transaction_type IS NULL OR transaction_type = 'other'; + +CREATE INDEX IF NOT EXISTS idx_transactions_type ON transactions(transaction_type); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 85f5402..f599c3d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -235,6 +235,7 @@ pub async fn import_confirm( let amount_cents = (tx.amount * 100.0).round() as i64; let label_anon = anonymize_label(&tx.label); + let transaction_type = detect_transaction_type(&tx.label, amount_cents); // Check if excluded from auto-categorization (checks, cash withdrawals/deposits) let excluded = is_excluded_from_auto_categorization(&tx.label); @@ -254,8 +255,8 @@ pub async fn import_confirm( } sqlx::query( - "INSERT INTO transactions (account_id, date, label, original_label, amount, note, fitid, import_batch_id, series_id, sub_series_id, is_auto_categorized, label_for_categorization) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + "INSERT INTO transactions (account_id, date, label, original_label, amount, note, fitid, import_batch_id, series_id, sub_series_id, is_auto_categorized, label_for_categorization, transaction_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" ) .bind(account_id) .bind(&tx.date) @@ -269,6 +270,7 @@ pub async fn import_confirm( .bind(sub_series_id) .bind(is_auto) .bind(&label_anon) + .bind(transaction_type) .execute(&mut *db_tx) .await .map_err(|e| format!("Erreur insertion transaction: {}", e))?; @@ -1062,6 +1064,33 @@ fn anonymize_label(label: &str) -> String { .to_uppercase() } +fn detect_transaction_type(label: &str, amount_cents: i64) -> &'static str { + let lower = label.to_lowercase(); + let matches_any = |patterns: &[&str]| patterns.iter().any(|pattern| lower.contains(pattern)); + + if matches_any(&["cheque", "chèque", "chq"]) { + "check" + } else if matches_any(&["retrait", "dab", "gab"]) { + "cash_withdrawal" + } else if matches_any(&["depot esp", "dépôt esp", "remise esp", "versement esp"]) { + "cash_deposit" + } else if matches_any(&["prlv", "prelevement", "prélèvement"]) { + "direct_debit" + } else if matches_any(&["vir", "virement", "transfert", "mouvement interne"]) { + "transfer" + } else if matches_any(&["cb", "carte"]) { + "card" + } else if matches_any(&["frais", "commission"]) { + "fee" + } else if matches_any(&["remb", "remboursement", "refund"]) { + "refund" + } else if amount_cents > 0 { + "income" + } else { + "other" + } +} + /// Check if a label corresponds to a check, cash withdrawal, or cash deposit (excluded from auto-cat) fn is_excluded_from_auto_categorization(label: &str) -> bool { static RE: OnceLock = OnceLock::new(); @@ -1462,4 +1491,18 @@ mod tests { assert!(!is_excluded_from_auto_categorization("CARREFOUR CB")); assert!(!is_excluded_from_auto_categorization("VIR SEPA SALAIRE")); } + + #[test] + fn test_detect_transaction_type() { + assert_eq!(detect_transaction_type("CARTE 17/03 CARREFOUR CB*1234", -4200), "card"); + assert_eq!(detect_transaction_type("VIR SEPA SALAIRE", 250000), "transfer"); + assert_eq!(detect_transaction_type("PRLV EDF", -8000), "direct_debit"); + assert_eq!(detect_transaction_type("CHEQUE 12345", -5000), "check"); + assert_eq!(detect_transaction_type("RETRAIT DAB 50 EUR", -5000), "cash_withdrawal"); + assert_eq!(detect_transaction_type("REMISE ESP", 5000), "cash_deposit"); + assert_eq!(detect_transaction_type("FRAIS TENUE DE COMPTE", -200), "fee"); + assert_eq!(detect_transaction_type("REMBOURSEMENT ASSURANCE", 1200), "refund"); + assert_eq!(detect_transaction_type("SALAIRE MARS", 250000), "income"); + assert_eq!(detect_transaction_type("ACHAT INCONNU", -1200), "other"); + } } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 57fd958..28c9ae8 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -80,5 +80,11 @@ pub fn get_migrations() -> Vec { sql: include_str!("../migrations/013_ai_settings.sql"), kind: MigrationKind::Up, }, + Migration { + version: 14, + description: "transaction_types", + sql: include_str!("../migrations/014_transaction_types.sql"), + kind: MigrationKind::Up, + }, ] } diff --git a/src/lib/stores/transactions.svelte.ts b/src/lib/stores/transactions.svelte.ts index 868e8fc..466655d 100644 --- a/src/lib/stores/transactions.svelte.ts +++ b/src/lib/stores/transactions.svelte.ts @@ -1,6 +1,6 @@ import { query, execute } from './db'; -import type { Transaction } from '$lib/types'; -import { toCents, anonymizeLabel, isExcludedFromAutoCategorization } from '$lib/utils/format'; +import type { Transaction, TransactionType } from '$lib/types'; +import { toCents, anonymizeLabel, isExcludedFromAutoCategorization, detectTransactionType } from '$lib/utils/format'; import { categorizationStore } from './categorization.svelte'; import { undoStore } from './undo.svelte'; @@ -18,6 +18,7 @@ class TransactionStore { filterSeriesId = $state(''); filterDateFrom = $state(''); filterDateTo = $state(''); + filterType = $state(''); private buildWhere(): { clause: string; params: unknown[] } { let clause = ' WHERE 1=1'; @@ -31,6 +32,10 @@ class TransactionStore { clause += ` AND t.series_id = $${i++}`; params.push(this.filterSeriesId); } + if (this.filterType) { + clause += ` AND t.transaction_type = $${i++}`; + params.push(this.filterType); + } if (this.search) { clause += ` AND t.label LIKE $${i++}`; params.push(`%${this.search}%`); @@ -114,24 +119,34 @@ class TransactionStore { series_id?: number | null; }) { const labelAnon = anonymizeLabel(data.label); + const amountCents = toCents(data.amount); + const transactionType = detectTransactionType(data.label, amountCents); await execute( - 'INSERT INTO transactions (account_id, date, label, amount, note, series_id, label_for_categorization) VALUES ($1, $2, $3, $4, $5, $6, $7)', - [data.account_id, data.date, data.label, toCents(data.amount), data.note ?? null, data.series_id ?? null, labelAnon || null] + 'INSERT INTO transactions (account_id, date, label, amount, note, series_id, label_for_categorization, transaction_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)', + [data.account_id, data.date, data.label, amountCents, data.note ?? null, data.series_id ?? null, labelAnon || null, transactionType] ); await this.load(); } - private static readonly ALLOWED_COLUMNS = new Set(['label', 'amount', 'date', 'note', 'series_id', 'account_id', 'is_reconciled']); + private static readonly ALLOWED_COLUMNS = new Set(['label', 'amount', 'date', 'note', 'series_id', 'account_id', 'is_reconciled', 'transaction_type']); - async update(id: number, data: Partial<{ label: string; amount: number; date: string; note: string | null; series_id: number | null; account_id: number; is_reconciled: number }>) { + async update(id: number, data: Partial<{ label: string; amount: number; date: string; note: string | null; series_id: number | null; account_id: number; is_reconciled: number; transaction_type: TransactionType }>) { const fields: string[] = []; const values: unknown[] = []; let i = 1; + let nextLabel: string | undefined; + let nextAmountCents: number | undefined; for (const [key, val] of Object.entries(data)) { if (val !== undefined && TransactionStore.ALLOWED_COLUMNS.has(key)) { fields.push(`${key} = $${i++}`); - values.push(key === 'amount' ? toCents(val as number) : val); + if (key === 'amount') { + nextAmountCents = toCents(val as number); + values.push(nextAmountCents); + } else { + if (key === 'label') nextLabel = val as string; + values.push(val); + } } } @@ -141,6 +156,14 @@ class TransactionStore { values.push(anonymizeLabel(data.label) || null); } + if ((data.label !== undefined || data.amount !== undefined) && data.transaction_type === undefined) { + const current = this.transactions.find((t) => t.id === id); + const label = nextLabel ?? current?.label ?? ''; + const amount = nextAmountCents ?? current?.amount ?? 0; + fields.push(`transaction_type = $${i++}`); + values.push(detectTransactionType(label, amount)); + } + if (fields.length > 0) { values.push(id); await execute(`UPDATE transactions SET ${fields.join(', ')} WHERE id = $${i}`, values); @@ -242,8 +265,8 @@ class TransactionStore { label: `Suppression de "${tx.label}"`, undo: async () => { await execute( - 'INSERT INTO transactions (account_id, date, label, original_label, amount, note, series_id, sub_series_id, label_for_categorization) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)', - [tx.account_id, tx.date, tx.label, tx.original_label, tx.amount, tx.note, tx.series_id, tx.sub_series_id, tx.label_for_categorization] + 'INSERT INTO transactions (account_id, date, label, original_label, amount, note, series_id, sub_series_id, label_for_categorization, transaction_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)', + [tx.account_id, tx.date, tx.label, tx.original_label, tx.amount, tx.note, tx.series_id, tx.sub_series_id, tx.label_for_categorization, tx.transaction_type] ); await this.load(); } diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 52f8f1d..619d651 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -36,6 +36,18 @@ export interface SubSeries { } // === Transactions === +export type TransactionType = + | 'card' + | 'transfer' + | 'direct_debit' + | 'check' + | 'cash_withdrawal' + | 'cash_deposit' + | 'fee' + | 'refund' + | 'income' + | 'other'; + export interface Transaction { id: number; account_id: number; @@ -52,6 +64,7 @@ export interface Transaction { label_for_categorization: string | null; is_auto_categorized: boolean; is_reconciled: boolean; + transaction_type: TransactionType; created_at: string; // Joined fields account_name?: string; diff --git a/src/lib/utils/format.test.ts b/src/lib/utils/format.test.ts index 8d8fc15..b50756e 100644 --- a/src/lib/utils/format.test.ts +++ b/src/lib/utils/format.test.ts @@ -7,7 +7,8 @@ import { formatMonth, anonymizeLabel, isExcludedFromAutoCategorization, - isTransferLabel + isTransferLabel, + detectTransactionType } from './format'; describe('toCents', () => { @@ -181,3 +182,21 @@ describe('isExcludedFromAutoCategorization', () => { expect(isExcludedFromAutoCategorization('PRLV EDF')).toBe(false); }); }); + +describe('detectTransactionType', () => { + it('detects common French bank labels', () => { + expect(detectTransactionType('CARTE 17/03 CARREFOUR CB*1234', -4200)).toBe('card'); + expect(detectTransactionType('VIR SEPA SALAIRE', 250000)).toBe('transfer'); + expect(detectTransactionType('PRLV EDF', -8000)).toBe('direct_debit'); + expect(detectTransactionType('CHEQUE 12345', -5000)).toBe('check'); + expect(detectTransactionType('RETRAIT DAB 50 EUR', -5000)).toBe('cash_withdrawal'); + expect(detectTransactionType('REMISE ESP', 5000)).toBe('cash_deposit'); + expect(detectTransactionType('FRAIS TENUE DE COMPTE', -200)).toBe('fee'); + expect(detectTransactionType('REMBOURSEMENT ASSURANCE', 1200)).toBe('refund'); + }); + + it('falls back to income or other based on sign', () => { + expect(detectTransactionType('SALAIRE MARS', 250000)).toBe('income'); + expect(detectTransactionType('ACHAT INCONNU', -1200)).toBe('other'); + }); +}); diff --git a/src/lib/utils/format.ts b/src/lib/utils/format.ts index 61fb9d2..6bf75b2 100644 --- a/src/lib/utils/format.ts +++ b/src/lib/utils/format.ts @@ -74,6 +74,33 @@ export function isTransferLabel(label: string): boolean { return /\b(vir(ement)?(\s+(sepa|emis|recu|interne))?|transfert|mouvement\s+interne)\b/.test(lower); } +export const TRANSACTION_TYPE_LABELS: Record = { + card: 'Carte', + transfer: 'Virement', + direct_debit: 'Prélèvement', + check: 'Chèque', + cash_withdrawal: 'Retrait', + cash_deposit: 'Dépôt espèces', + fee: 'Frais', + refund: 'Remboursement', + income: 'Revenu', + other: 'Autre' +}; + +export function detectTransactionType(label: string, amountCents: number): string { + const lower = label.toLowerCase(); + if (/\b(cheque|chèque|chq)\b/.test(lower)) return 'check'; + if (/\b(retrait|dab|gab)\b/.test(lower)) return 'cash_withdrawal'; + if (/\b(depot\s*esp|dépôt\s*esp|remise\s*esp|versement\s*esp)\b/.test(lower)) return 'cash_deposit'; + if (/\b(prlv|prelevement|prélèvement)\b/.test(lower)) return 'direct_debit'; + if (isTransferLabel(label)) return 'transfer'; + if (/\b(cb|carte)\b/.test(lower)) return 'card'; + if (/\b(frais|commission)\b/.test(lower)) return 'fee'; + if (/\b(remb|remboursement|refund)\b/.test(lower)) return 'refund'; + if (amountCents > 0) return 'income'; + return 'other'; +} + export const BUDGET_AREA_COLORS: Record = { income: 'text-income', recurring: 'text-accent', diff --git a/src/routes/accounts/+page.svelte b/src/routes/accounts/+page.svelte index ef28810..0fd8c79 100644 --- a/src/routes/accounts/+page.svelte +++ b/src/routes/accounts/+page.svelte @@ -43,8 +43,8 @@ // Create an adjustment transaction const label = diff > 0 ? 'Ajustement de solde (+)' : 'Ajustement de solde (-)'; await query( - 'INSERT INTO transactions (account_id, date, label, amount, note) VALUES ($1, $2, $3, $4, $5)', - [correctionAccountId, new Date().toISOString().slice(0, 10), label, diff, 'Correction manuelle du solde'] + 'INSERT INTO transactions (account_id, date, label, amount, note, transaction_type) VALUES ($1, $2, $3, $4, $5, $6)', + [correctionAccountId, new Date().toISOString().slice(0, 10), label, diff, 'Correction manuelle du solde', 'other'] ); await accountStore.load(); await loadSparklines(); diff --git a/src/routes/transactions/+page.svelte b/src/routes/transactions/+page.svelte index d18620b..ce90340 100644 --- a/src/routes/transactions/+page.svelte +++ b/src/routes/transactions/+page.svelte @@ -7,7 +7,7 @@ import { accountStore } from '$lib/stores/accounts.svelte'; import { budgetStore } from '$lib/stores/budget.svelte'; import { splitStore } from '$lib/stores/splits.svelte'; - import { formatCurrency, formatDate, toEuros } from '$lib/utils/format'; + import { formatCurrency, formatDate, toEuros, TRANSACTION_TYPE_LABELS } from '$lib/utils/format'; import { confidentialStore } from '$lib/stores/confidential.svelte'; import type { Transaction } from '$lib/types'; import LoadingSpinner from '$lib/components/LoadingSpinner.svelte'; @@ -261,6 +261,7 @@ transactionStore.search = ''; transactionStore.filterAccountId = ''; transactionStore.filterSeriesId = ''; + transactionStore.filterType = ''; transactionStore.filterDateFrom = ''; transactionStore.filterDateTo = ''; transactionStore.load(); @@ -571,6 +572,16 @@ {/each} + - {#if transactionStore.search || transactionStore.filterAccountId || transactionStore.filterSeriesId || filterReconciled || transactionStore.filterDateFrom || transactionStore.filterDateTo} + {#if transactionStore.search || transactionStore.filterAccountId || transactionStore.filterSeriesId || transactionStore.filterType || filterReconciled || transactionStore.filterDateFrom || transactionStore.filterDateTo} @@ -718,6 +729,11 @@ {#if tx.note}

{tx.note}

{/if} + {#if tx.transaction_type && tx.transaction_type !== 'other'} + + {TRANSACTION_TYPE_LABELS[tx.transaction_type]} + + {/if} {tx.account_name ?? ''} @@ -823,6 +839,9 @@ {#if tx.series_name} · {tx.series_name} {/if} + {#if tx.transaction_type && tx.transaction_type !== 'other'} + · {TRANSACTION_TYPE_LABELS[tx.transaction_type]} + {/if}