Simpl-Resultat/src/services/importedFileService.ts
le king fu a58f8b98bc fix(onboarding): count an import only once the file brought a transaction
The "Import a first file" step counted `imported_files` rows in status
`completed`. `executeImport` writes that row before its first insert, so
an import that added nothing (a file holding only its header, or one
whose rows were all in error in the preview) ticked the step: the Import
card then said "Done." at rest and sent the user to /transactions, which
had nothing to categorize and led back to the import.

The counter now keeps a completed file only when a transaction points at
it (`EXISTS` on `transactions.file_id`, covered by
`idx_transactions_file`). Replayed with sqlite3 on
`consolidated_schema.sql`: 0 after an import without a valid row, 0
before a successful import's first insert and 1 after, 0 once the file
turns to `error`. Duplicates only change nothing: their transactions
came with an earlier completed file, and a restore writes its own.

The tests run without SQLite, so the rule is pinned on the query, and
the Import page is pinned at rest after such an import: step 4 again,
on the first source card, without "Done.".

Review of #398 (PR #404), option A chosen by Max.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:05:14 -04:00

190 lines
6.1 KiB
TypeScript

import { getDb } from "./db";
import { notifyDataChanged } from "./dataEvents";
import { isLinkedTransactionFkError } from "./balance.service";
import { TransactionLinkedToBalanceError } from "./transactionService";
import type { ImportedFile, ImportedFileWithSource } from "../shared/types";
export async function getFilesBySourceId(
sourceId: number
): Promise<ImportedFile[]> {
const db = await getDb();
return db.select<ImportedFile[]>(
"SELECT * FROM imported_files WHERE source_id = $1 ORDER BY import_date DESC",
[sourceId]
);
}
export async function existsByHash(
fileHash: string
): Promise<ImportedFile | null> {
const db = await getDb();
const rows = await db.select<ImportedFile[]>(
"SELECT * FROM imported_files WHERE file_hash = $1",
[fileHash]
);
return rows.length > 0 ? rows[0] : null;
}
export async function createImportedFile(file: {
source_id: number;
filename: string;
file_hash: string;
row_count: number;
status: string;
notes?: string;
}): Promise<number> {
const db = await getDb();
// Check if file already exists by filename (e.g. re-import of same file)
const existing = await db.select<ImportedFile[]>(
"SELECT id FROM imported_files WHERE source_id = $1 AND filename = $2",
[file.source_id, file.filename]
);
// Both branches write the status that the "import a first file" step reads
// (`status = 'completed'`, counted once the file brought a transaction), so
// each one is announced (ADR 0022).
if (existing.length > 0) {
await db.execute(
`UPDATE imported_files SET file_hash = $1, row_count = $2, status = $3, notes = $4, import_date = CURRENT_TIMESTAMP WHERE id = $5`,
[file.file_hash, file.row_count, file.status, file.notes || null, existing[0].id]
);
notifyDataChanged();
return existing[0].id;
}
const result = await db.execute(
`INSERT INTO imported_files (source_id, filename, file_hash, row_count, status, notes)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
file.source_id,
file.filename,
file.file_hash,
file.row_count,
file.status,
file.notes || null,
]
);
notifyDataChanged();
return result.lastInsertId as number;
}
export async function updateFileStatus(
id: number,
status: string,
rowCount?: number,
notes?: string
): Promise<void> {
const db = await getDb();
await db.execute(
`UPDATE imported_files SET status = $1, row_count = COALESCE($2, row_count), notes = COALESCE($3, notes) WHERE id = $4`,
[status, rowCount ?? null, notes ?? null, id]
);
notifyDataChanged();
}
export async function getAllImportedFiles(): Promise<ImportedFileWithSource[]> {
const db = await getDb();
return db.select<ImportedFileWithSource[]>(
`SELECT f.*, s.name AS source_name
FROM imported_files f
JOIN import_sources s ON s.id = f.source_id
ORDER BY f.import_date DESC`
);
}
export async function deleteImportWithTransactions(
fileId: number
): Promise<number> {
const db = await getDb();
// Look up the source_id before deleting
const files = await db.select<ImportedFile[]>(
"SELECT source_id FROM imported_files WHERE id = $1",
[fileId]
);
const sourceId = files.length > 0 ? files[0].source_id : null;
// Pre-flight: if any transaction in this file is linked to a balance
// account via `balance_account_transfers`, the FK RESTRICT will fire on
// the bulk DELETE. Surface a typed error BEFORE touching the row so the
// UI can prompt the user to unlink first (Issue #142).
const linked = await db.select<
Array<{ transaction_id: number; account_id: number; account_name: string; direction: "in" | "out" }>
>(
`SELECT bat.transaction_id AS transaction_id,
bat.account_id AS account_id,
a.name AS account_name,
bat.direction AS direction
FROM balance_account_transfers bat
JOIN transactions t ON t.id = bat.transaction_id
JOIN balance_accounts a ON a.id = bat.account_id
WHERE t.file_id = $1`,
[fileId]
);
if (linked.length > 0) {
throw new TransactionLinkedToBalanceError(null, linked);
}
let result;
try {
result = await db.execute(
"DELETE FROM transactions WHERE file_id = $1",
[fileId]
);
} catch (err) {
if (isLinkedTransactionFkError(err)) {
throw new TransactionLinkedToBalanceError(null, []);
}
throw err;
}
await db.execute("DELETE FROM imported_files WHERE id = $1", [fileId]);
// Clean up orphaned source if no files remain
if (sourceId) {
const remaining = await db.select<Array<{ cnt: number }>>(
"SELECT COUNT(*) AS cnt FROM imported_files WHERE source_id = $1",
[sourceId]
);
if (remaining[0]?.cnt === 0) {
await db.execute("DELETE FROM import_sources WHERE id = $1", [sourceId]);
}
}
// Announced once every delete went through; a refused or failed delete
// throws above and announces nothing (ADR 0022).
notifyDataChanged();
return result.rowsAffected;
}
export async function deleteAllImportsWithTransactions(): Promise<number> {
const db = await getDb();
// Same pre-flight as the per-file path: if ANY transaction is linked to
// a balance account, the bulk wipe would explode on FK RESTRICT — surface
// a typed error so the UI can prompt the user to unlink first.
const linked = await db.select<
Array<{ transaction_id: number; account_id: number; account_name: string; direction: "in" | "out" }>
>(
`SELECT bat.transaction_id AS transaction_id,
bat.account_id AS account_id,
a.name AS account_name,
bat.direction AS direction
FROM balance_account_transfers bat
JOIN balance_accounts a ON a.id = bat.account_id
LIMIT 50`
);
if (linked.length > 0) {
throw new TransactionLinkedToBalanceError(null, linked);
}
let result;
try {
result = await db.execute("DELETE FROM transactions");
} catch (err) {
if (isLinkedTransactionFkError(err)) {
throw new TransactionLinkedToBalanceError(null, []);
}
throw err;
}
await db.execute("DELETE FROM imported_files");
await db.execute("DELETE FROM import_sources");
notifyDataChanged();
return result.rowsAffected;
}