Skip to content
Merged
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
781 changes: 781 additions & 0 deletions e2e/reimbursement-splits.e2e.cjs

Large diffs are not rendered by default.

Binary file added e2e/screenshots/01-setup-page.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/02-setup-filled.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/03-dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/04-add-transaction-modal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/05-transactions-list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/06-reimbursement-badge.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/07-edit-reimbursement-form.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/08-edit-split-state.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/09-add-form-open.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/10-split-mode-income.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/11-reimbursement-toggled.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/12-after-reset.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added e2e/screenshots/error-fatal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
252 changes: 227 additions & 25 deletions packages/client/src/components/BankSyncPanel.tsx

Large diffs are not rendered by default.

233 changes: 211 additions & 22 deletions packages/client/src/pages/ImportPage.tsx

Large diffs are not rendered by default.

627 changes: 627 additions & 0 deletions packages/server/src/db/demo-seed.ts

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions packages/server/src/db/migrate-dismissed-transfers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Database from 'better-sqlite3';

export function migrateDismissedTransfers(sqlite: Database.Database): void {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS dismissed_transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id),
signature TEXT NOT NULL,
date TEXT NOT NULL,
amount REAL NOT NULL,
description TEXT NOT NULL,
dismissed_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS dismissed_transfers_acct_sig_idx
ON dismissed_transfers(account_id, signature);
`);
}
13 changes: 13 additions & 0 deletions packages/server/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,16 @@ export const budgetRecurring = sqliteTable('budget_recurring', {
created_at: text('created_at').default('CURRENT_TIMESTAMP'),
updated_at: text('updated_at').default('CURRENT_TIMESTAMP'),
});

// === Dismissed Transfers ===
export const dismissedTransfers = sqliteTable('dismissed_transfers', {
id: integer('id').primaryKey({ autoIncrement: true }),
account_id: integer('account_id').notNull().references(() => accounts.id),
signature: text('signature').notNull(),
date: text('date').notNull(),
amount: real('amount').notNull(),
description: text('description').notNull(),
dismissed_at: text('dismissed_at').default('CURRENT_TIMESTAMP'),
}, (table) => [
uniqueIndex('dismissed_transfers_acct_sig_idx').on(table.account_id, table.signature),
]);
12 changes: 12 additions & 0 deletions packages/server/src/db/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,18 @@ async function seed() {
declining_rate REAL,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS dismissed_transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id),
signature TEXT NOT NULL,
date TEXT NOT NULL,
amount REAL NOT NULL,
description TEXT NOT NULL,
dismissed_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS dismissed_transfers_acct_sig_idx
ON dismissed_transfers(account_id, signature);
`);

// --- Categories ---
Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { migrate2FA } from './db/migrate-2fa.js';
import { migrateCategorySortOrder } from './db/migrate-category-sort-order.js';
import { migrateTransactionSplits } from './db/migrate-transaction-splits.js';
import { migrateBudgetTemplatesRecurring } from './db/migrate-budget-templates-recurring.js';
import { migrateDismissedTransfers } from './db/migrate-dismissed-transfers.js';
import { authenticate } from './middleware/auth.js';
import authRoutes from './routes/auth.js';
import accountRoutes from './routes/accounts.js';
Expand Down Expand Up @@ -54,6 +55,7 @@ migrate2FA(sqlite);
migrateCategorySortOrder(sqlite);
migrateTransactionSplits(sqlite);
migrateBudgetTemplatesRecurring(sqlite);
migrateDismissedTransfers(sqlite);

app.use(helmet({ contentSecurityPolicy: false }));
app.use(cors(isProd ? { origin: false } : { origin: 'http://localhost:5173', credentials: true }));
Expand Down
73 changes: 71 additions & 2 deletions packages/server/src/routes/import.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Router, Request, Response } from 'express';
import multer from 'multer';
import { db } from '../db/index.js';
import { transactions, categories, transactionSplits } from '../db/schema.js';
import { db, sqlite } from '../db/index.js';
import { transactions, categories, transactionSplits, dismissedTransfers } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { requirePermission } from '../middleware/permissions.js';
import { detectDuplicates } from '../services/duplicateDetector.js';
Expand Down Expand Up @@ -452,4 +452,73 @@ router.post('/check-transfers', requirePermission('import.csv'), (req: Request,
}
});

// Generate a stable signature for matching transfers across imports
function transferSignature(date: string, amount: number, description: string): string {
const normDesc = description.toLowerCase().trim().replace(/\s+/g, ' ');
const normAmt = Math.round(amount * 100) / 100;
return `${date}|${normAmt}|${normDesc}`;
}

// POST /api/import/dismiss-transfers — record transfers as "seen" so they collapse on next import
router.post('/dismiss-transfers', requirePermission('import.csv'), (req: Request, res: Response) => {
try {
const { accountId, items } = req.body as {
accountId: number;
items: { date: string; amount: number; description: string }[];
};
if (!accountId || !items || !Array.isArray(items) || items.length === 0) {
res.status(400).json({ error: 'accountId and items array are required' });
return;
}

const insert = sqlite.prepare(
`INSERT OR IGNORE INTO dismissed_transfers (account_id, signature, date, amount, description, dismissed_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))`
);
const batch = sqlite.transaction(() => {
for (const item of items) {
const sig = transferSignature(item.date, item.amount, item.description);
insert.run(accountId, sig, item.date, item.amount, item.description);
}
});
batch();

res.json({ data: { dismissed: items.length } });
} catch (err) {
console.error('POST /import/dismiss-transfers error:', err);
res.status(500).json({ error: 'Failed to dismiss transfers' });
}
});

// POST /api/import/check-dismissed-transfers — check which items were previously dismissed
router.post('/check-dismissed-transfers', requirePermission('import.csv'), (req: Request, res: Response) => {
try {
const { accountId, items } = req.body as {
accountId: number;
items: { date: string; amount: number; description: string }[];
};
if (!accountId || !items || !Array.isArray(items)) {
res.status(400).json({ error: 'accountId and items array are required' });
return;
}

// Build set of dismissed signatures for this account
const dismissed = db.select({ signature: dismissedTransfers.signature })
.from(dismissedTransfers)
.where(eq(dismissedTransfers.account_id, accountId))
.all();
const dismissedSet = new Set(dismissed.map((d) => d.signature));

const results = items.map((item) => {
const sig = transferSignature(item.date, item.amount, item.description);
return dismissedSet.has(sig);
});

res.json({ data: results });
} catch (err) {
console.error('POST /import/check-dismissed-transfers error:', err);
res.status(500).json({ error: 'Failed to check dismissed transfers' });
}
});

export default router;
Loading