feat(imports): import history and delete by import
Track imports in DB, show history in Data section, allow deleting transactions of a specific import instead of clearing all.
This commit is contained in:
@@ -7,6 +7,7 @@ import { requireAuth } from './middleware/auth';
|
||||
|
||||
import authRouter from './routes/auth';
|
||||
import importRouter from './routes/import';
|
||||
import importsRouter from './routes/imports';
|
||||
import transactionsRouter from './routes/transactions';
|
||||
import accountsRouter from './routes/accounts';
|
||||
import categoriesRouter from './routes/categories';
|
||||
@@ -26,6 +27,7 @@ app.use('/api/auth', authRouter);
|
||||
// All remaining /api routes require authentication
|
||||
app.use('/api', requireAuth);
|
||||
app.use('/api/import', importRouter);
|
||||
app.use('/api/imports', importsRouter);
|
||||
app.use('/api/transactions', transactionsRouter);
|
||||
app.use('/api/accounts', accountsRouter);
|
||||
app.use('/api/categories', categoriesRouter);
|
||||
|
||||
@@ -133,6 +133,24 @@ const migrations: { name: string; sql: string }[] = [
|
||||
AND NOT EXISTS (SELECT 1 FROM category_rules LIMIT 1);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: '005_imports_table',
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS imports (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
account_id BIGINT REFERENCES accounts(id),
|
||||
bank TEXT NOT NULL,
|
||||
account_number_masked TEXT NOT NULL,
|
||||
imported_count INT NOT NULL,
|
||||
duplicates_skipped INT NOT NULL,
|
||||
total_in_file INT NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE transactions
|
||||
ADD COLUMN IF NOT EXISTS import_id BIGINT REFERENCES imports(id);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: '004_seed_category_rules_extended',
|
||||
sql: `
|
||||
@@ -176,6 +194,24 @@ const migrations: { name: string; sql: string }[] = [
|
||||
AND EXISTS (SELECT 1 FROM categories LIMIT 1);
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: '005_imports_table',
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS imports (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
account_id BIGINT REFERENCES accounts(id),
|
||||
bank TEXT NOT NULL,
|
||||
account_number_masked TEXT NOT NULL,
|
||||
imported_count INT NOT NULL,
|
||||
duplicates_skipped INT NOT NULL,
|
||||
total_in_file INT NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE transactions
|
||||
ADD COLUMN IF NOT EXISTS import_id BIGINT REFERENCES imports(id);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export async function runMigrations(): Promise<void> {
|
||||
|
||||
36
backend/src/routes/imports.ts
Normal file
36
backend/src/routes/imports.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Router } from 'express';
|
||||
import { pool } from '../db/pool';
|
||||
import { asyncHandler } from '../utils';
|
||||
import * as importsService from '../services/imports';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (_req, res) => {
|
||||
const imports = await importsService.getImports();
|
||||
res.json(imports);
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({ error: 'BAD_REQUEST', message: 'Invalid import id' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { rows } = await pool.query('SELECT 1 FROM imports WHERE id = $1', [id]);
|
||||
if (rows.length === 0) {
|
||||
res.status(404).json({ error: 'NOT_FOUND', message: 'Import not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await importsService.deleteImport(id);
|
||||
res.json(result);
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -155,6 +155,16 @@ export async function importStatement(
|
||||
isNewAccount = true;
|
||||
}
|
||||
|
||||
// Create import record (counts updated after loop)
|
||||
const accountNumberMasked = maskAccountNumber(data.statement.accountNumber);
|
||||
const importResult = await client.query(
|
||||
`INSERT INTO imports (account_id, bank, account_number_masked, imported_count, duplicates_skipped, total_in_file)
|
||||
VALUES ($1, $2, $3, 0, 0, $4)
|
||||
RETURNING id`,
|
||||
[accountId, data.bank, accountNumberMasked, data.transactions.length],
|
||||
);
|
||||
const importId = Number(importResult.rows[0].id);
|
||||
|
||||
// Insert transactions
|
||||
const insertedIds: number[] = [];
|
||||
|
||||
@@ -164,11 +174,11 @@ export async function importStatement(
|
||||
|
||||
const result = await client.query(
|
||||
`INSERT INTO transactions
|
||||
(account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, FALSE)
|
||||
(account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed, import_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, FALSE, $8)
|
||||
ON CONFLICT (account_id, fingerprint) DO NOTHING
|
||||
RETURNING id`,
|
||||
[accountId, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp],
|
||||
[accountId, tx.operationAt, tx.amountSigned, tx.commission, tx.description, dir, fp, importId],
|
||||
);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
@@ -176,6 +186,13 @@ export async function importStatement(
|
||||
}
|
||||
}
|
||||
|
||||
// Update import record with actual counts
|
||||
const duplicatesSkipped = data.transactions.length - insertedIds.length;
|
||||
await client.query(
|
||||
`UPDATE imports SET imported_count = $1, duplicates_skipped = $2 WHERE id = $3`,
|
||||
[insertedIds.length, duplicatesSkipped, importId],
|
||||
);
|
||||
|
||||
// Auto-categorize newly inserted transactions
|
||||
if (insertedIds.length > 0) {
|
||||
await client.query(
|
||||
@@ -208,7 +225,7 @@ export async function importStatement(
|
||||
return {
|
||||
accountId,
|
||||
isNewAccount,
|
||||
accountNumberMasked: maskAccountNumber(data.statement.accountNumber),
|
||||
accountNumberMasked,
|
||||
imported: insertedIds.length,
|
||||
duplicatesSkipped: data.transactions.length - insertedIds.length,
|
||||
totalInFile: data.transactions.length,
|
||||
|
||||
44
backend/src/services/imports.ts
Normal file
44
backend/src/services/imports.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { pool } from '../db/pool';
|
||||
import type { Import } from '@family-budget/shared';
|
||||
|
||||
export async function getImports(): Promise<Import[]> {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
i.id,
|
||||
i.imported_at,
|
||||
i.account_id,
|
||||
a.alias AS account_alias,
|
||||
i.bank,
|
||||
i.account_number_masked,
|
||||
i.imported_count,
|
||||
i.duplicates_skipped,
|
||||
i.total_in_file
|
||||
FROM imports i
|
||||
LEFT JOIN accounts a ON a.id = i.account_id
|
||||
ORDER BY i.imported_at DESC`,
|
||||
);
|
||||
|
||||
return result.rows.map((r) => ({
|
||||
id: Number(r.id),
|
||||
importedAt: r.imported_at.toISOString(),
|
||||
accountId: r.account_id != null ? Number(r.account_id) : null,
|
||||
accountAlias: r.account_alias ?? null,
|
||||
bank: r.bank,
|
||||
accountNumberMasked: r.account_number_masked,
|
||||
importedCount: Number(r.imported_count),
|
||||
duplicatesSkipped: Number(r.duplicates_skipped),
|
||||
totalInFile: Number(r.total_in_file),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function deleteImport(id: number): Promise<{ deleted: number }> {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM transactions WHERE import_id = $1 RETURNING id',
|
||||
[id],
|
||||
);
|
||||
const deleted = result.rowCount ?? 0;
|
||||
|
||||
await pool.query('DELETE FROM imports WHERE id = $1', [id]);
|
||||
|
||||
return { deleted };
|
||||
}
|
||||
@@ -171,5 +171,6 @@ export async function updateTransaction(
|
||||
|
||||
export async function clearAllTransactions(): Promise<{ deleted: number }> {
|
||||
const result = await pool.query('DELETE FROM transactions RETURNING id');
|
||||
await pool.query('DELETE FROM imports');
|
||||
return { deleted: result.rowCount ?? 0 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user