Skip to content

Commit 1c84cf6

Browse files
committed
more iban fixes not there yet
1 parent 16437c9 commit 1c84cf6

13 files changed

Lines changed: 387 additions & 97 deletions

File tree

apps/api/src/db/index.ts

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ export const db: DatabaseType = new Database(DB_PATH);
1818
// Enable WAL mode for better performance
1919
db.pragma('journal_mode = WAL');
2020

21+
// Enable foreign keys for cascading deletes
22+
db.pragma('foreign_keys = ON');
23+
2124
// Initialize database schema
2225
export function initializeDatabase(): void {
2326
const schemaPath = join(__dirname, 'schema.sql');
@@ -29,9 +32,6 @@ export function initializeDatabase(): void {
2932
// Update categories to Dutch if they're still in English
3033
updateCategoriesToDutch();
3134

32-
// Update categories to Dutch if they're still in English
33-
updateCategoriesToDutch();
34-
3535
// Seed defaults if table is empty
3636
seedDefaultCategories();
3737

@@ -104,6 +104,13 @@ export function initializeDatabase(): void {
104104
} catch {
105105
// Column already exists
106106
}
107+
try {
108+
db.exec(
109+
'ALTER TABLE imports ADD COLUMN profile_id INTEGER DEFAULT 1 REFERENCES profiles(id) ON DELETE CASCADE'
110+
);
111+
} catch {
112+
// Column already exists
113+
}
107114

108115
// Add payment_method column to transactions if it doesn't exist
109116
try {
@@ -128,11 +135,14 @@ export function initializeDatabase(): void {
128135

129136
// Ensure we have a usable unique constraint for import_hash (skip duplicates on import)
130137
try {
138+
// Drop old global index if it exists
139+
db.exec('DROP INDEX IF EXISTS idx_transactions_import_hash');
140+
// Create new profile-scoped unique index
131141
db.exec(
132-
"CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_import_hash ON transactions(import_hash) WHERE import_hash IS NOT NULL AND TRIM(import_hash) != ''"
142+
"CREATE UNIQUE INDEX IF NOT EXISTS idx_transactions_import_hash_scoped ON transactions(import_hash, profile_id) WHERE import_hash IS NOT NULL AND TRIM(import_hash) != ''"
133143
);
134-
} catch {
135-
// Ignore index creation failures (e.g. on corrupted/unsupported schema)
144+
} catch (error) {
145+
console.warn('Failed to update import_hash index (might be expected):', error);
136146
}
137147

138148
// Backfill missing import_hash values so older data also dedupes correctly
@@ -312,12 +322,71 @@ export function initializeDatabase(): void {
312322
// MAJOR MIGRATION: Consolidate shared_iban_merchants into address_book
313323
consolidateSharedIbanMerchantsIntoAddressBook();
314324

315-
// MIGRATION: Multi-tenant profile support
316-
// migrateToMultiTenant(); // Commented out since schema.sql already defines the columns
325+
// Ensure all data has a profile_id
326+
backfillMissingProfileIds();
327+
328+
// Ensure all transactions have the correct profile_id from their account
329+
syncTransactionProfileIds();
317330

318331
console.warn('Database initialized successfully');
319332
}
320333

334+
/**
335+
* Ensures all existing records have a profile_id
336+
*/
337+
function backfillMissingProfileIds(): void {
338+
try {
339+
// Get default profile ID (usually 1, but check DB to be sure)
340+
const result = db
341+
.prepare('SELECT id FROM profiles ORDER BY created_at ASC LIMIT 1')
342+
.get() as { id: number } | undefined;
343+
const defaultProfileId = result?.id ?? 1;
344+
345+
const tables = [
346+
'accounts',
347+
'transactions',
348+
'categories',
349+
'budgets',
350+
'category_rules',
351+
'address_book',
352+
'imports',
353+
];
354+
355+
for (const table of tables) {
356+
const result = db
357+
.prepare(`UPDATE ${table} SET profile_id = ? WHERE profile_id IS NULL`)
358+
.run(defaultProfileId);
359+
if (result.changes > 0) {
360+
console.warn(`Backfilled profile_id for ${result.changes} rows in ${table}`);
361+
}
362+
}
363+
} catch (error) {
364+
console.error('Failed to backfill profile IDs:', error);
365+
}
366+
}
367+
368+
/**
369+
* Ensures all transactions have the correct profile_id based on their account
370+
*/
371+
function syncTransactionProfileIds(): void {
372+
try {
373+
const result = db
374+
.prepare(
375+
`
376+
UPDATE transactions
377+
SET profile_id = (SELECT profile_id FROM accounts WHERE accounts.id = transactions.account_id)
378+
WHERE profile_id IS NULL OR profile_id != (SELECT profile_id FROM accounts WHERE accounts.id = transactions.account_id)
379+
`
380+
)
381+
.run();
382+
if (result.changes > 0) {
383+
console.warn(`Synced profile_id for ${result.changes} transactions`);
384+
}
385+
} catch (error) {
386+
console.error('Failed to sync transaction profile IDs:', error);
387+
}
388+
}
389+
321390
/**
322391
* Migration for multi-tenant profile support
323392
* Creates profiles table and adds profile_id columns to existing tables

apps/api/src/db/schema.sql

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ CREATE TABLE IF NOT EXISTS profiles (
1717
name TEXT NOT NULL,
1818
type TEXT CHECK(type IN ('personal', 'business', 'shared', 'savings')) DEFAULT 'personal',
1919
avatar_url TEXT,
20+
is_hidden INTEGER DEFAULT 0,
2021
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
22+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
2123
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
2224
);
2325

@@ -34,7 +36,8 @@ CREATE TABLE IF NOT EXISTS accounts (
3436
current_balance DECIMAL(10,2),
3537
order_index INTEGER DEFAULT 0,
3638
profile_id INTEGER DEFAULT 1 REFERENCES profiles(id) ON DELETE CASCADE,
37-
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
39+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
40+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
3841
);
3942

4043
-- Categories table
@@ -46,7 +49,8 @@ CREATE TABLE IF NOT EXISTS categories (
4649
color TEXT,
4750
description TEXT,
4851
profile_id INTEGER REFERENCES profiles(id) ON DELETE CASCADE,
49-
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
52+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
53+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
5054
);
5155

5256
-- Transactions table
@@ -65,9 +69,11 @@ CREATE TABLE IF NOT EXISTS transactions (
6569
balance_after DECIMAL(10,2),
6670
payment_method TEXT,
6771
raw_data JSON,
68-
import_hash TEXT UNIQUE,
72+
import_hash TEXT,
6973
profile_id INTEGER REFERENCES profiles(id) ON DELETE CASCADE,
70-
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
74+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
75+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
76+
UNIQUE(import_hash, profile_id)
7177
);
7278

7379
-- Budgets table
@@ -79,7 +85,8 @@ CREATE TABLE IF NOT EXISTS budgets (
7985
start_date DATE,
8086
end_date DATE,
8187
profile_id INTEGER DEFAULT 1 REFERENCES profiles(id) ON DELETE CASCADE,
82-
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
88+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
89+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
8390
);
8491

8592
-- Category rules for auto-categorization
@@ -89,7 +96,8 @@ CREATE TABLE IF NOT EXISTS category_rules (
8996
category_id INTEGER REFERENCES categories(id) ON DELETE CASCADE,
9097
priority INTEGER DEFAULT 0,
9198
profile_id INTEGER DEFAULT 1 REFERENCES profiles(id) ON DELETE CASCADE,
92-
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
99+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
100+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
93101
);
94102

95103
-- Imports tracking table
@@ -102,7 +110,10 @@ CREATE TABLE IF NOT EXISTS imports (
102110
status TEXT CHECK(status IN ('pending', 'completed', 'failed')) DEFAULT 'pending',
103111
skipped_rows JSON,
104112
duplicates_skipped INTEGER DEFAULT 0,
105-
parse_errors INTEGER DEFAULT 0
113+
parse_errors INTEGER DEFAULT 0,
114+
profile_id INTEGER DEFAULT 1 REFERENCES profiles(id) ON DELETE CASCADE,
115+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
116+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
106117
);
107118

108119

apps/api/src/routes/import.ts

Lines changed: 26 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,10 @@ function addToAddressBookIfNew(
218218
return result.changes > 0;
219219
}
220220

221-
function getExistingImportHashes(): Set<string> {
221+
function getExistingImportHashes(profileId: number | string): Set<string> {
222222
const rows = query<{ import_hash: string }>(
223-
"SELECT import_hash FROM transactions WHERE import_hash IS NOT NULL AND TRIM(import_hash) != ''"
223+
"SELECT import_hash FROM transactions WHERE profile_id = ? AND import_hash IS NOT NULL AND TRIM(import_hash) != ''",
224+
[profileId]
224225
);
225226
return new Set(rows.map((row) => row.import_hash));
226227
}
@@ -300,11 +301,11 @@ router.post('/csv', upload.single('file'), async (req, res) => {
300301
});
301302
}
302303

303-
// Get or create account based on IBAN
304+
// Get or create account based on IBAN (scoped to profile)
304305
const iban = ingTransactions[0].rekening;
305306
let account = queryOne<{ id: number; profile_id: number }>(
306-
'SELECT id, profile_id FROM accounts WHERE iban = ?',
307-
[iban]
307+
'SELECT id, profile_id FROM accounts WHERE iban = ? AND profile_id = ?',
308+
[iban, profileId]
308309
);
309310

310311
if (!account) {
@@ -319,8 +320,8 @@ router.post('/csv', upload.single('file'), async (req, res) => {
319320
// Convert to our transaction format
320321
const transactions = convertINGToTransactions(ingTransactions, account.id);
321322

322-
// Skip any entries that already exist (all months, including current month)
323-
const existingHashes = getExistingImportHashes();
323+
// Skip any entries that already exist (scoped to profile)
324+
const existingHashes = getExistingImportHashes(profileId);
324325
const seenInThisFile = new Set<string>();
325326
const newTransactions = [] as typeof transactions;
326327
const skippedExisting = [] as typeof transactions;
@@ -444,14 +445,15 @@ router.post('/csv', upload.single('file'), async (req, res) => {
444445

445446
// Create import record with skipped rows
446447
const importResult = run(
447-
'INSERT INTO imports (filename, bank, transaction_count, status, duplicates_skipped, skipped_rows) VALUES (?, ?, ?, ?, ?, ?)',
448+
'INSERT INTO imports (filename, bank, transaction_count, status, duplicates_skipped, skipped_rows, profile_id) VALUES (?, ?, ?, ?, ?, ?, ?)',
448449
[
449450
filename,
450451
bank,
451452
categorizedTransactions.length,
452453
'pending',
453454
skippedExisting.length + skippedInFile.length,
454455
JSON.stringify(skippedRowsData),
456+
profileId,
455457
]
456458
);
457459
const importId = Number(importResult.lastInsertRowid);
@@ -734,32 +736,26 @@ router.get('/history', (req, res) => {
734736
parse_errors: number | null;
735737
}>(
736738
`
737-
SELECT DISTINCT i.id, i.filename, i.bank, i.imported_at, i.transaction_count, i.status,
738-
i.skipped_rows, i.duplicates_skipped, i.parse_errors
739-
FROM imports i
740-
INNER JOIN transactions t ON t.profile_id = ?
741-
WHERE i.imported_at >= (
742-
SELECT MIN(t2.created_at) FROM transactions t2 WHERE t2.profile_id = ?
743-
)
744-
ORDER BY i.imported_at DESC
739+
SELECT id, filename, bank, imported_at, transaction_count, status,
740+
skipped_rows, duplicates_skipped, parse_errors
741+
FROM imports
742+
WHERE profile_id = ?
743+
ORDER BY imported_at DESC
745744
LIMIT 10
746745
`,
747-
[profileId, profileId]
746+
[profileId]
748747
);
749748

750749
// Clean up old imports (keep only 10 most recent per profile)
751750
run(
752751
`
753752
DELETE FROM imports
754-
WHERE id NOT IN (
753+
WHERE profile_id = ?
754+
AND id NOT IN (
755755
SELECT id FROM (
756-
SELECT DISTINCT i.id
757-
FROM imports i
758-
INNER JOIN transactions t ON t.profile_id = ?
759-
WHERE i.imported_at >= (
760-
SELECT MIN(t2.created_at) FROM transactions t2 WHERE t2.profile_id = ?
761-
)
762-
ORDER BY i.imported_at DESC
756+
SELECT id FROM imports
757+
WHERE profile_id = ?
758+
ORDER BY imported_at DESC
763759
LIMIT 10
764760
)
765761
)
@@ -1002,12 +998,8 @@ router.post('/generic/import', upload.single('file'), async (req, res) => {
1002998
transactions.push(...rowTx);
1003999
}
10041000

1005-
// Get ALL existing hashes to check for duplicates (global check due to UNIQUE constraint)
1006-
const existingHashes = new Set(
1007-
query<{ import_hash: string }>(
1008-
'SELECT import_hash FROM transactions WHERE import_hash IS NOT NULL'
1009-
).map((row) => row.import_hash)
1010-
);
1001+
// Get ALL existing hashes to check for duplicates (scoped to profile)
1002+
const existingHashes = getExistingImportHashes(profileId);
10111003

10121004
// Also track hashes within this import to avoid duplicates in the CSV itself
10131005
const seenInThisImport = new Set<string>();
@@ -1042,15 +1034,16 @@ router.post('/generic/import', upload.single('file'), async (req, res) => {
10421034

10431035
// Create import record
10441036
const importResult = run(
1045-
`INSERT INTO imports (filename, bank, transaction_count, status, skipped_rows, duplicates_skipped, parse_errors)
1046-
VALUES (?, ?, ?, 'pending', ?, ?, ?)`,
1037+
`INSERT INTO imports (filename, bank, transaction_count, status, skipped_rows, duplicates_skipped, parse_errors, profile_id)
1038+
VALUES (?, ?, ?, 'pending', ?, ?, ?, ?)`,
10471039
[
10481040
filename,
10491041
req.body.bank || 'generic',
10501042
newTransactions.length,
10511043
JSON.stringify([...errors, ...duplicateErrors]),
10521044
duplicateCount,
10531045
errors.length,
1046+
profileId,
10541047
]
10551048
);
10561049
const importId = importResult.lastInsertRowid as number;

apps/web/src/components/settings/ProfileManager.tsx

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -172,12 +172,16 @@ export function ProfileManager() {
172172
}, 300);
173173
};
174174

175-
// Get avatar options for display, with selected gradient first if it's a gradient
175+
// Get avatar options for display, making sure the current one is included if it's a gradient
176176
const getDisplayAvatars = () => {
177177
const selectedAvatar = formData.avatarUrl;
178-
if (selectedAvatar && avatarOptions.includes(selectedAvatar)) {
179-
const filtered = avatarOptions.filter((a) => a !== selectedAvatar);
180-
return [selectedAvatar, ...filtered];
178+
if (
179+
selectedAvatar &&
180+
typeof selectedAvatar === 'string' &&
181+
selectedAvatar.startsWith('linear-gradient') &&
182+
!avatarOptions.includes(selectedAvatar)
183+
) {
184+
return [selectedAvatar, ...avatarOptions];
181185
}
182186
return avatarOptions;
183187
};
@@ -204,11 +208,20 @@ export function ProfileManager() {
204208
// Store the newly created profile ID and move to accounts step
205209
if (newProfile && typeof newProfile === 'object' && 'id' in newProfile) {
206210
setNewlyCreatedProfileId(newProfile.id as string);
211+
setCreateStep('accounts');
212+
setCreatedAccounts([]);
213+
} else {
214+
setToastMessage(
215+
t.settings?.profileManager?.createError ||
216+
'Er ging iets mis bij het aanmaken van het profiel.'
217+
);
207218
}
208-
setCreateStep('accounts');
209-
setCreatedAccounts([]);
210219
} catch (error) {
211220
console.error('Failed to create profile', error);
221+
setToastMessage(
222+
t.settings?.profileManager?.createError ||
223+
'Er ging iets mis bij het aanmaken van het profiel.'
224+
);
212225
}
213226
};
214227

@@ -340,7 +353,12 @@ export function ProfileManager() {
340353
<DialogTrigger asChild>
341354
<Button
342355
onClick={() => {
343-
setFormData({ name: '', type: 'personal' });
356+
// Default to first gradient if available
357+
setFormData({
358+
name: '',
359+
type: 'personal',
360+
avatarUrl: avatarOptions[0] || null,
361+
});
344362
setCreateStep('profile');
345363
}}
346364
>

0 commit comments

Comments
 (0)