Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src-tauri/migrations/014_transaction_types.sql
Original file line number Diff line number Diff line change
@@ -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);
47 changes: 45 additions & 2 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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)
Expand All @@ -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))?;
Expand Down Expand Up @@ -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<regex::Regex> = OnceLock::new();
Expand Down Expand Up @@ -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");
}
}
6 changes: 6 additions & 0 deletions src-tauri/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,11 @@ pub fn get_migrations() -> Vec<Migration> {
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,
},
]
}
41 changes: 32 additions & 9 deletions src/lib/stores/transactions.svelte.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -18,6 +18,7 @@ class TransactionStore {
filterSeriesId = $state<number | string>('');
filterDateFrom = $state('');
filterDateTo = $state('');
filterType = $state<TransactionType | ''>('');

private buildWhere(): { clause: string; params: unknown[] } {
let clause = ' WHERE 1=1';
Expand All @@ -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}%`);
Expand Down Expand Up @@ -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);
}
}
}

Expand All @@ -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);
Expand Down Expand Up @@ -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();
}
Expand Down
13 changes: 13 additions & 0 deletions src/lib/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
21 changes: 20 additions & 1 deletion src/lib/utils/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
formatMonth,
anonymizeLabel,
isExcludedFromAutoCategorization,
isTransferLabel
isTransferLabel,
detectTransactionType
} from './format';

describe('toCents', () => {
Expand Down Expand Up @@ -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');
});
});
27 changes: 27 additions & 0 deletions src/lib/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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<string, string> = {
income: 'text-income',
recurring: 'text-accent',
Expand Down
4 changes: 2 additions & 2 deletions src/routes/accounts/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
23 changes: 21 additions & 2 deletions src/routes/transactions/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -261,6 +261,7 @@
transactionStore.search = '';
transactionStore.filterAccountId = '';
transactionStore.filterSeriesId = '';
transactionStore.filterType = '';
transactionStore.filterDateFrom = '';
transactionStore.filterDateTo = '';
transactionStore.load();
Expand Down Expand Up @@ -571,6 +572,16 @@
<option value={series.id}>{series.name}</option>
{/each}
</select>
<select
bind:value={transactionStore.filterType}
onchange={handleSearch}
class="rounded-xl border border-border bg-bg-card/60 px-4 py-2.5 text-[13px] text-text-primary outline-none focus-ring"
>
<option value="">Tous types</option>
{#each Object.entries(TRANSACTION_TYPE_LABELS) as [type, label]}
<option value={type}>{label}</option>
{/each}
</select>
<select
bind:value={filterReconciled}
class="rounded-xl border border-border bg-bg-card/60 px-4 py-2.5 text-[13px] text-text-primary outline-none focus-ring"
Expand All @@ -579,7 +590,7 @@
<option value="yes">Pointées</option>
<option value="no">Non pointées</option>
</select>
{#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}
<button onclick={() => { clearFilters(); filterReconciled = ''; transactionStore.filterDateFrom = ''; transactionStore.filterDateTo = ''; }} class="text-[12px] font-medium text-accent hover:text-accent-hover transition-smooth">
Effacer
</button>
Expand Down Expand Up @@ -718,6 +729,11 @@
{#if tx.note}
<p class="text-[11px] text-text-muted mt-0.5">{tx.note}</p>
{/if}
{#if tx.transaction_type && tx.transaction_type !== 'other'}
<span class="mt-1.5 inline-flex rounded-md bg-bg-elevated px-1.5 py-0.5 text-[10px] font-medium text-text-muted">
{TRANSACTION_TYPE_LABELS[tx.transaction_type]}
</span>
{/if}
</td>
<td class="px-5 py-3.5 text-[13px] text-text-muted">{tx.account_name ?? ''}</td>
<td class="px-5 py-3.5">
Expand Down Expand Up @@ -823,6 +839,9 @@
{#if tx.series_name}
<span class="text-accent/70"> · {tx.series_name}</span>
{/if}
{#if tx.transaction_type && tx.transaction_type !== 'other'}
<span> · {TRANSACTION_TYPE_LABELS[tx.transaction_type]}</span>
{/if}
</p>
</div>

Expand Down