17 Commits

Author SHA1 Message Date
Anton
01b1f26553 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.
2026-03-16 17:46:15 +03:00
f32a21f87a Merge pull request 'Revert SSE streaming for PDF import, use synchronous flow' (#11) from revert/remove-sse-streaming into main
Reviewed-on: #11
2026-03-14 17:13:00 +00:00
vakabunga
8b57dd987e Revert SSE streaming for PDF import, use synchronous flow
SSE streaming added unnecessary complexity and latency due to
buffering issues across Node.js event loop, Nginx proxy, and
Docker layers. Reverted to a simple synchronous request/response
for PDF conversion. Kept extractLlmErrorMessage for user-friendly
LLM errors, lazy-loaded pdf-parse, and extended Nginx timeout.
2026-03-14 20:12:27 +03:00
ea234ea007 Merge pull request 'fix: yield to event loop after each SSE write to flush socket' (#10) from fix/sse-event-loop-flush into main
Reviewed-on: #10
2026-03-14 17:00:51 +00:00
vakabunga
db4d5e4d00 fix: yield to event loop after each SSE write to flush socket
The for-await loop over OpenAI stream chunks runs synchronously when
data is buffered, causing res.write() calls to queue without flushing.
Add setImmediate yield after each progress event so the event loop
reaches its I/O phase and pushes data to the network immediately.
2026-03-14 19:59:22 +03:00
358fcaeff5 Merge pull request 'fix: disable gzip and pad SSE events to prevent proxy buffering' (#9) from fix/sse-gzip-buffering into main
Reviewed-on: #9
2026-03-14 16:46:07 +00:00
vakabunga
67fed57118 fix: disable gzip and pad SSE events to prevent proxy buffering
Add gzip off to Nginx import location — the global gzip on was
buffering text/event-stream responses. Pad each SSE event to 4 KB
with comment lines to push past any remaining proxy buffer threshold.
2026-03-14 19:45:33 +03:00
45a6f3d374 Merge pull request 'fix: eliminate SSE buffering through Nginx proxy' (#8) from fix/sse-proxy-buffering into main
Reviewed-on: #8
2026-03-14 14:31:16 +00:00
vakabunga
aaf8cacf75 fix: eliminate SSE buffering through Nginx proxy
Add 2 KB padding comment after headers to push past proxy buffer
threshold, enable TCP_NODELAY on the socket, and remove erroneous
chunked_transfer_encoding off from Nginx that caused full response
buffering.
2026-03-14 17:30:52 +03:00
vakabunga
e28d0f46d0 fix: reopen result modal from progress bar, faster progress, handle LLM context error
- Move import modal visibility into ImportContext so the Layout
  progress pill can reopen the result dialog after the modal was closed.
- Raise LLM progress cap from 85% to 98% and drop the intermediate
  -import 88%- SSE event to eliminate the visual stall after LLM finishes.
- Detect LLM context-length errors (n_keep >= n_ctx) and surface a
  clear message instead of generic -Временная ошибка конвертации-.
2026-03-14 17:05:55 +03:00
22be09c101 Merge pull request 'fix: adaptive LLM progress estimation and emit 85% on stream end' (#6) from fix/adaptive-llm-progress into main
Reviewed-on: #6
2026-03-14 13:43:27 +00:00
vakabunga
78c4730196 fix: adaptive LLM progress estimation and emit 85% on stream end
Hardcoded EXPECTED_CHARS (15k) caused progress to stall at ~20-25% for
short statements. Now expected size is derived from input text length.
Also emit an explicit 85% event when the LLM stream finishes, and
throttle SSE events to 300ms to reduce browser overhead.
2026-03-14 16:41:12 +03:00
f2d0c91488 Merge pull request 'feat: stream PDF import progress via SSE with global progress bar' (#5) from feature/pdf-import-sse-streaming into main
Reviewed-on: #5
2026-03-14 13:18:57 +00:00
vakabunga
1d7fbea9ef feat: stream PDF import progress via SSE with global progress bar
Replace the synchronous PDF import with Server-Sent Events streaming
between the backend (LLM) and the browser. The user can now close the
import modal and continue working while the conversion runs — a fixed
progress bar in the Layout shows real-time stage and percentage.
2026-03-14 16:18:31 +03:00
vakabunga
627706228b fix: remove response_format incompatible with LM Studio API 2026-03-14 15:23:33 +03:00
vakabunga
a5f2294440 fix: adds copy of node modules from backend folder 2026-03-14 15:14:04 +03:00
25ddd6b7ed Merge pull request 'fix: lazy-load pdf-parse to prevent startup crash if module is missing' (#4) from fix-lazyload-pdfparse-crash into main
Reviewed-on: #4
2026-03-14 11:18:55 +00:00
16 changed files with 362 additions and 8 deletions

1
.gitignore vendored
View File

@@ -23,3 +23,4 @@ history.xlsx
match_analysis.py match_analysis.py
match_report.txt match_report.txt
statements/ statements/
.cursor/

View File

@@ -30,6 +30,7 @@ ENV NODE_ENV=production
COPY --from=build /app/backend/dist ./dist COPY --from=build /app/backend/dist ./dist
COPY --from=build /app/backend/package.json ./package.json COPY --from=build /app/backend/package.json ./package.json
COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/backend/node_modules/ ./node_modules/
COPY backend/.env .env COPY backend/.env .env
EXPOSE 3000 EXPOSE 3000

View File

@@ -7,6 +7,7 @@ import { requireAuth } from './middleware/auth';
import authRouter from './routes/auth'; import authRouter from './routes/auth';
import importRouter from './routes/import'; import importRouter from './routes/import';
import importsRouter from './routes/imports';
import transactionsRouter from './routes/transactions'; import transactionsRouter from './routes/transactions';
import accountsRouter from './routes/accounts'; import accountsRouter from './routes/accounts';
import categoriesRouter from './routes/categories'; import categoriesRouter from './routes/categories';
@@ -26,6 +27,7 @@ app.use('/api/auth', authRouter);
// All remaining /api routes require authentication // All remaining /api routes require authentication
app.use('/api', requireAuth); app.use('/api', requireAuth);
app.use('/api/import', importRouter); app.use('/api/import', importRouter);
app.use('/api/imports', importsRouter);
app.use('/api/transactions', transactionsRouter); app.use('/api/transactions', transactionsRouter);
app.use('/api/accounts', accountsRouter); app.use('/api/accounts', accountsRouter);
app.use('/api/categories', categoriesRouter); app.use('/api/categories', categoriesRouter);

View File

@@ -133,6 +133,24 @@ const migrations: { name: string; sql: string }[] = [
AND NOT EXISTS (SELECT 1 FROM category_rules LIMIT 1); 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', name: '004_seed_category_rules_extended',
sql: ` sql: `
@@ -176,6 +194,24 @@ const migrations: { name: string; sql: string }[] = [
AND EXISTS (SELECT 1 FROM categories LIMIT 1); 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> { export async function runMigrations(): Promise<void> {

View 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;

View File

@@ -155,6 +155,16 @@ export async function importStatement(
isNewAccount = true; 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 // Insert transactions
const insertedIds: number[] = []; const insertedIds: number[] = [];
@@ -164,11 +174,11 @@ export async function importStatement(
const result = await client.query( const result = await client.query(
`INSERT INTO transactions `INSERT INTO transactions
(account_id, operation_at, amount_signed, commission, description, direction, fingerprint, category_id, is_category_confirmed) (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) VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, FALSE, $8)
ON CONFLICT (account_id, fingerprint) DO NOTHING ON CONFLICT (account_id, fingerprint) DO NOTHING
RETURNING id`, 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) { 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 // Auto-categorize newly inserted transactions
if (insertedIds.length > 0) { if (insertedIds.length > 0) {
await client.query( await client.query(
@@ -208,7 +225,7 @@ export async function importStatement(
return { return {
accountId, accountId,
isNewAccount, isNewAccount,
accountNumberMasked: maskAccountNumber(data.statement.accountNumber), accountNumberMasked,
imported: insertedIds.length, imported: insertedIds.length,
duplicatesSkipped: data.transactions.length - insertedIds.length, duplicatesSkipped: data.transactions.length - insertedIds.length,
totalInFile: data.transactions.length, totalInFile: data.transactions.length,

View 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 };
}

View File

@@ -108,6 +108,7 @@ export async function convertPdfToStatement(
const openai = new OpenAI({ const openai = new OpenAI({
apiKey: config.llmApiKey, apiKey: config.llmApiKey,
...(config.llmApiBaseUrl && { baseURL: config.llmApiBaseUrl }), ...(config.llmApiBaseUrl && { baseURL: config.llmApiBaseUrl }),
timeout: 5 * 60 * 1000,
}); });
try { try {
@@ -119,7 +120,6 @@ export async function convertPdfToStatement(
], ],
temperature: 0, temperature: 0,
max_tokens: 32768, max_tokens: 32768,
response_format: { type: 'json_object' },
}); });
const content = completion.choices[0]?.message?.content?.trim(); const content = completion.choices[0]?.message?.content?.trim();
@@ -159,7 +159,21 @@ export async function convertPdfToStatement(
return { return {
status: 502, status: 502,
error: 'BAD_GATEWAY', error: 'BAD_GATEWAY',
message: 'Временная ошибка конвертации', message: extractLlmErrorMessage(err),
}; };
} }
} }
function extractLlmErrorMessage(err: unknown): string {
const raw = String(
(err as Record<string, unknown>)?.message ??
(err as Record<string, Record<string, unknown>>)?.error?.message ?? '',
);
if (/context.length|n_ctx|too.many.tokens|maximum.context/i.test(raw)) {
return 'PDF-файл слишком большой для обработки. Попробуйте файл с меньшим количеством операций или используйте модель с большим контекстным окном.';
}
if (/timeout|timed?\s*out|ETIMEDOUT|ECONNREFUSED/i.test(raw)) {
return 'LLM-сервер не отвечает. Проверьте, что сервер запущен и доступен.';
}
return 'Временная ошибка конвертации';
}

View File

@@ -171,5 +171,6 @@ export async function updateTransaction(
export async function clearAllTransactions(): Promise<{ deleted: number }> { export async function clearAllTransactions(): Promise<{ deleted: number }> {
const result = await pool.query('DELETE FROM transactions RETURNING id'); const result = await pool.query('DELETE FROM transactions RETURNING id');
await pool.query('DELETE FROM imports');
return { deleted: result.rowCount ?? 0 }; return { deleted: result.rowCount ?? 0 };
} }

View File

@@ -3,6 +3,20 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Import endpoint — long timeout for LLM processing
location /api/import {
proxy_pass http://family-budget-backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cookie_path / /;
proxy_connect_timeout 5s;
proxy_read_timeout 600s;
client_max_body_size 15m;
}
# API — проксируем на backend (сервис из docker-compose) # API — проксируем на backend (сервис из docker-compose)
location /api { location /api {
proxy_pass http://family-budget-backend:3000; proxy_pass http://family-budget-backend:3000;

View File

@@ -1,4 +1,7 @@
import type { ImportStatementResponse } from '@family-budget/shared'; import type {
ImportStatementResponse,
Import,
} from '@family-budget/shared';
import { api } from './client'; import { api } from './client';
export async function importStatement( export async function importStatement(
@@ -11,3 +14,13 @@ export async function importStatement(
formData, formData,
); );
} }
export async function getImports(): Promise<Import[]> {
return api.get<Import[]>('/api/imports');
}
export async function deleteImport(
id: number,
): Promise<{ deleted: number }> {
return api.delete<{ deleted: number }>(`/api/imports/${id}`);
}

View File

@@ -1,13 +1,87 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { ClearHistoryModal } from './ClearHistoryModal'; import { ClearHistoryModal } from './ClearHistoryModal';
import { DeleteImportModal } from './DeleteImportModal';
import { getImports } from '../api/import';
import type { Import } from '@family-budget/shared';
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function DataSection() { export function DataSection() {
const [showClearModal, setShowClearModal] = useState(false); const [showClearModal, setShowClearModal] = useState(false);
const [imports, setImports] = useState<Import[]>([]);
const [impToDelete, setImpToDelete] = useState<Import | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
getImports().then(setImports).catch(() => {});
}, []);
const handleImportDeleted = () => {
setImpToDelete(null);
getImports().then(setImports).catch(() => {});
navigate('/history');
};
return ( return (
<div className="data-section"> <div className="data-section">
<div className="section-block">
<h3>История импортов</h3>
<p className="section-desc">
Список импортов выписок. Можно удалить операции конкретного импорта.
</p>
{imports.length === 0 ? (
<p className="muted">Импортов пока нет.</p>
) : (
<div className="table-responsive">
<table className="table">
<thead>
<tr>
<th>Дата</th>
<th>Счёт</th>
<th>Банк</th>
<th>Импортировано</th>
<th>Дубликаты</th>
<th></th>
</tr>
</thead>
<tbody>
{imports.map((imp) => (
<tr key={imp.id}>
<td>{formatDate(imp.importedAt)}</td>
<td>
{imp.accountAlias || imp.accountNumberMasked || '—'}
</td>
<td>{imp.bank}</td>
<td>{imp.importedCount}</td>
<td>{imp.duplicatesSkipped}</td>
<td>
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => setImpToDelete(imp)}
disabled={imp.importedCount === 0}
>
Удалить
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="section-block"> <div className="section-block">
<h3>Очистка данных</h3> <h3>Очистка данных</h3>
<p className="section-desc"> <p className="section-desc">
@@ -32,6 +106,14 @@ export function DataSection() {
}} }}
/> />
)} )}
{impToDelete && (
<DeleteImportModal
imp={impToDelete}
onClose={() => setImpToDelete(null)}
onDone={handleImportDeleted}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,70 @@
import { useState } from 'react';
import { deleteImport } from '../api/import';
import type { Import } from '@family-budget/shared';
interface Props {
imp: Import;
onClose: () => void;
onDone: () => void;
}
export function DeleteImportModal({ imp, onClose, onDone }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const accountLabel =
imp.accountAlias || imp.accountNumberMasked || `ID ${imp.accountId}`;
const handleConfirm = async () => {
setLoading(true);
setError('');
try {
await deleteImport(imp.id);
onDone();
} catch (e) {
setError(
e instanceof Error ? e.message : 'Ошибка при удалении импорта',
);
} finally {
setLoading(false);
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>Удалить импорт</h2>
<button className="btn-close" onClick={onClose}>
&times;
</button>
</div>
<div className="modal-body">
<p className="clear-history-warn">
Будут удалены все операции этого импорта ({imp.importedCount}{' '}
шт.): {imp.bank} / {accountLabel}
</p>
{error && <div className="alert alert-error">{error}</div>}
<p>Действие необратимо.</p>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-danger"
onClick={handleConfirm}
disabled={loading}
>
{loading ? 'Удаление…' : 'Удалить'}
</button>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Отмена
</button>
</div>
</div>
</div>
);
}

9
package-lock.json generated
View File

@@ -111,6 +111,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -1394,6 +1395,7 @@
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/body-parser": "*", "@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0", "@types/express-serve-static-core": "^5.0.0",
@@ -1472,6 +1474,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -1611,6 +1614,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -2734,6 +2738,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.11.0", "pg-connection-string": "^2.11.0",
"pg-pool": "^3.12.0", "pg-pool": "^3.12.0",
@@ -2831,6 +2836,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -2980,6 +2986,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -2989,6 +2996,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -4100,6 +4108,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",

View File

@@ -1,3 +1,16 @@
/** Import record from imports table (for import history) */
export interface Import {
id: number;
importedAt: string;
accountId: number | null;
accountAlias: string | null;
bank: string;
accountNumberMasked: string;
importedCount: number;
duplicatesSkipped: number;
totalInFile: number;
}
/** JSON 1.0 statement file — the shape accepted by POST /api/import/statement */ /** JSON 1.0 statement file — the shape accepted by POST /api/import/statement */
export interface StatementFile { export interface StatementFile {
schemaVersion: '1.0'; schemaVersion: '1.0';

View File

@@ -37,6 +37,7 @@ export type {
StatementHeader, StatementHeader,
StatementTransaction, StatementTransaction,
ImportStatementResponse, ImportStatementResponse,
Import,
} from './import'; } from './import';
export type { export type {