diff --git a/src/lib/stores/transactions.svelte.ts b/src/lib/stores/transactions.svelte.ts
index 868e8fc..da74989 100644
--- a/src/lib/stores/transactions.svelte.ts
+++ b/src/lib/stores/transactions.svelte.ts
@@ -6,6 +6,81 @@ import { undoStore } from './undo.svelte';
const PAGE_SIZE = 200;
+export type TransactionCategorizationFilter = '' | 'categorized' | 'uncategorized';
+export type TransactionReconciledFilter = '' | 'yes' | 'no';
+
+export interface TransactionFilters {
+ accountId: number | string;
+ seriesId: number | string;
+ search: string;
+ dateFrom: string;
+ dateTo: string;
+ amountMin: string;
+ amountMax: string;
+ categorization: TransactionCategorizationFilter;
+ reconciled: TransactionReconciledFilter;
+}
+
+function parseAmountFilter(value: string): number | null {
+ const normalized = value.trim().replace(',', '.');
+ if (!normalized) return null;
+ const parsed = Number(normalized);
+ return Number.isFinite(parsed) ? toCents(parsed) : null;
+}
+
+export function buildTransactionWhere(filters: TransactionFilters): { clause: string; params: unknown[] } {
+ let clause = ' WHERE 1=1';
+ const params: unknown[] = [];
+ let i = 1;
+
+ if (filters.accountId) {
+ clause += ` AND t.account_id = $${i++}`;
+ params.push(filters.accountId);
+ }
+ if (filters.seriesId) {
+ clause += ` AND t.series_id = $${i++}`;
+ params.push(filters.seriesId);
+ }
+ if (filters.search) {
+ clause += ` AND t.label LIKE $${i++}`;
+ params.push(`%${filters.search}%`);
+ }
+ if (filters.dateFrom) {
+ clause += ` AND t.date >= $${i++}`;
+ params.push(filters.dateFrom);
+ }
+ if (filters.dateTo) {
+ clause += ` AND t.date <= $${i++}`;
+ params.push(filters.dateTo);
+ }
+
+ const amountMin = parseAmountFilter(filters.amountMin);
+ if (amountMin !== null) {
+ clause += ` AND t.amount >= $${i++}`;
+ params.push(amountMin);
+ }
+
+ const amountMax = parseAmountFilter(filters.amountMax);
+ if (amountMax !== null) {
+ clause += ` AND t.amount <= $${i++}`;
+ params.push(amountMax);
+ }
+
+ if (filters.categorization === 'categorized') {
+ clause += ' AND t.series_id IS NOT NULL';
+ } else if (filters.categorization === 'uncategorized') {
+ clause += ' AND t.series_id IS NULL';
+ }
+
+ if (filters.reconciled === 'yes') {
+ clause += ' AND t.is_reconciled = 1';
+ } else if (filters.reconciled === 'no') {
+ clause += ' AND COALESCE(t.is_reconciled, 0) = 0';
+ }
+
+ return { clause, params };
+}
+
class TransactionStore {
transactions = $state([]);
loading = $state(false);
@@ -18,32 +93,23 @@ class TransactionStore {
filterSeriesId = $state('');
filterDateFrom = $state('');
filterDateTo = $state('');
+ filterAmountMin = $state('');
+ filterAmountMax = $state('');
+ filterCategorization = $state('');
+ filterReconciled = $state('');
private buildWhere(): { clause: string; params: unknown[] } {
- let clause = ' WHERE 1=1';
- const params: unknown[] = [];
- let i = 1;
- if (this.filterAccountId) {
- clause += ` AND t.account_id = $${i++}`;
- params.push(this.filterAccountId);
- }
- if (this.filterSeriesId) {
- clause += ` AND t.series_id = $${i++}`;
- params.push(this.filterSeriesId);
- }
- if (this.search) {
- clause += ` AND t.label LIKE $${i++}`;
- params.push(`%${this.search}%`);
- }
- if (this.filterDateFrom) {
- clause += ` AND t.date >= $${i++}`;
- params.push(this.filterDateFrom);
- }
- if (this.filterDateTo) {
- clause += ` AND t.date <= $${i++}`;
- params.push(this.filterDateTo);
- }
- return { clause, params };
+ return buildTransactionWhere({
+ accountId: this.filterAccountId,
+ seriesId: this.filterSeriesId,
+ search: this.search,
+ dateFrom: this.filterDateFrom,
+ dateTo: this.filterDateTo,
+ amountMin: this.filterAmountMin,
+ amountMax: this.filterAmountMax,
+ categorization: this.filterCategorization,
+ reconciled: this.filterReconciled
+ });
}
async load() {
diff --git a/src/lib/stores/transactions.test.ts b/src/lib/stores/transactions.test.ts
new file mode 100644
index 0000000..4551ea9
--- /dev/null
+++ b/src/lib/stores/transactions.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildTransactionWhere } from './transactions.svelte';
+
+describe('buildTransactionWhere', () => {
+ it('builds account, category, search and date filters with ordered params', () => {
+ const result = buildTransactionWhere({
+ accountId: 2,
+ seriesId: 7,
+ search: 'carrefour',
+ dateFrom: '2026-01-01',
+ dateTo: '2026-01-31',
+ amountMin: '',
+ amountMax: '',
+ categorization: '',
+ reconciled: ''
+ });
+
+ expect(result.clause).toContain('t.account_id = $1');
+ expect(result.clause).toContain('t.series_id = $2');
+ expect(result.clause).toContain('t.label LIKE $3');
+ expect(result.clause).toContain('t.date >= $4');
+ expect(result.clause).toContain('t.date <= $5');
+ expect(result.params).toEqual([2, 7, '%carrefour%', '2026-01-01', '2026-01-31']);
+ });
+
+ it('converts amount bounds to cents', () => {
+ const result = buildTransactionWhere({
+ accountId: '',
+ seriesId: '',
+ search: '',
+ dateFrom: '',
+ dateTo: '',
+ amountMin: '-25,50',
+ amountMax: '100.25',
+ categorization: '',
+ reconciled: ''
+ });
+
+ expect(result.clause).toContain('t.amount >= $1');
+ expect(result.clause).toContain('t.amount <= $2');
+ expect(result.params).toEqual([-2550, 10025]);
+ });
+
+ it('ignores invalid amount bounds', () => {
+ const result = buildTransactionWhere({
+ accountId: '',
+ seriesId: '',
+ search: '',
+ dateFrom: '',
+ dateTo: '',
+ amountMin: 'abc',
+ amountMax: ' ',
+ categorization: '',
+ reconciled: ''
+ });
+
+ expect(result.clause).toBe(' WHERE 1=1');
+ expect(result.params).toEqual([]);
+ });
+
+ it('filters categorized and uncategorized transactions without extra params', () => {
+ const categorized = buildTransactionWhere({
+ accountId: '',
+ seriesId: '',
+ search: '',
+ dateFrom: '',
+ dateTo: '',
+ amountMin: '',
+ amountMax: '',
+ categorization: 'categorized',
+ reconciled: ''
+ });
+ const uncategorized = buildTransactionWhere({
+ accountId: '',
+ seriesId: '',
+ search: '',
+ dateFrom: '',
+ dateTo: '',
+ amountMin: '',
+ amountMax: '',
+ categorization: 'uncategorized',
+ reconciled: ''
+ });
+
+ expect(categorized.clause).toContain('t.series_id IS NOT NULL');
+ expect(uncategorized.clause).toContain('t.series_id IS NULL');
+ expect(categorized.params).toEqual([]);
+ expect(uncategorized.params).toEqual([]);
+ });
+
+ it('filters reconciled and unreconciled transactions without extra params', () => {
+ const reconciled = buildTransactionWhere({
+ accountId: '',
+ seriesId: '',
+ search: '',
+ dateFrom: '',
+ dateTo: '',
+ amountMin: '',
+ amountMax: '',
+ categorization: '',
+ reconciled: 'yes'
+ });
+ const unreconciled = buildTransactionWhere({
+ accountId: '',
+ seriesId: '',
+ search: '',
+ dateFrom: '',
+ dateTo: '',
+ amountMin: '',
+ amountMax: '',
+ categorization: '',
+ reconciled: 'no'
+ });
+
+ expect(reconciled.clause).toContain('t.is_reconciled = 1');
+ expect(unreconciled.clause).toContain('COALESCE(t.is_reconciled, 0) = 0');
+ expect(reconciled.params).toEqual([]);
+ expect(unreconciled.params).toEqual([]);
+ });
+});
diff --git a/src/routes/transactions/+page.svelte b/src/routes/transactions/+page.svelte
index d18620b..d75f7b4 100644
--- a/src/routes/transactions/+page.svelte
+++ b/src/routes/transactions/+page.svelte
@@ -263,6 +263,10 @@
transactionStore.filterSeriesId = '';
transactionStore.filterDateFrom = '';
transactionStore.filterDateTo = '';
+ transactionStore.filterAmountMin = '';
+ transactionStore.filterAmountMax = '';
+ transactionStore.filterCategorization = '';
+ transactionStore.filterReconciled = '';
transactionStore.load();
}
@@ -273,16 +277,10 @@
toastStore.success(newValue ? 'Transaction pointée' : 'Pointage annulé');
}
- let filterReconciled = $state<'' | 'yes' | 'no'>('');
let hasExternalFilter = $state(false);
let externalFilterLabel = $state('');
- let filteredTransactions = $derived.by(() => {
- let txs = transactionStore.transactions;
- if (filterReconciled === 'yes') txs = txs.filter(t => t.is_reconciled);
- if (filterReconciled === 'no') txs = txs.filter(t => !t.is_reconciled);
- return txs;
- });
+ let filteredTransactions = $derived(transactionStore.transactions);
function formatDateShort(dateStr: string) {
const d = new Date(dateStr);
@@ -310,7 +308,7 @@
Filtre : {externalFilterLabel}