Milestones 0 and 1: foundation and money in (20261007.10)
Foundation: single arm64 container (Hono + SQLite via Drizzle, React + Vite), local sign-in with long-lived sessions and passkeys, roles (read-only, full access, admin), nightly and pre-upgrade backups, light and dark themes, date-based version numbers, and the ideas lightbulb behind IDEAS_ENABLED. Money in: accounts with balances, CSV import with bank detection, a column mapping pop-up for unknown layouts, duplicate checks, transfer pairing and undo; one-tap review with learned rules; activity with search, splits, tags, notes and cash entries. Statement files are blocked from commits by a pre-commit hook; tests use synthetic fixtures only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
534
server/src/import/service.ts
Normal file
534
server/src/import/service.ts
Normal file
@@ -0,0 +1,534 @@
|
||||
import type { AccountKind, ImportColumnMap, ImportPreview, ImportSummary } from '@spendlight/shared';
|
||||
import { and, between, count, desc, eq, inArray } from 'drizzle-orm';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { extname, join } from 'node:path';
|
||||
import { logChange } from '../audit';
|
||||
import { Suggester } from '../categorize/suggest';
|
||||
import type { AppContext } from '../context';
|
||||
import { accounts, importLayouts, imports, transactions, type AccountRow } from '../db/schema';
|
||||
import { fail } from '../http';
|
||||
import { detectBuiltIn, findHeaderRow, guessMap, headerSignature, type ResolvedLayout } from './layouts';
|
||||
import { cleanMerchant, dedupeKey } from './merchant';
|
||||
import { daysBetween, parseCents, parseDate, readTable, shiftDate } from './parse';
|
||||
import { pairTransfers } from './transfers';
|
||||
|
||||
// The import pipeline: stage a file, preview what it would add, then commit.
|
||||
// Nothing is written until commit, and every commit can be undone whole.
|
||||
|
||||
const MAX_BYTES = 10 * 1024 * 1024;
|
||||
const STAGE_TTL_MS = 60 * 60_000;
|
||||
|
||||
interface Staged {
|
||||
id: string;
|
||||
fileName: string;
|
||||
text: string;
|
||||
hash: string;
|
||||
userId: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export class StagingStore {
|
||||
private files = new Map<string, Staged>();
|
||||
|
||||
add(fileName: string, text: string, userId: number): Staged {
|
||||
this.sweep();
|
||||
const staged = {
|
||||
id: randomUUID(),
|
||||
fileName,
|
||||
text,
|
||||
hash: createHash('sha256').update(text).digest('hex'),
|
||||
userId,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.files.set(staged.id, staged);
|
||||
return staged;
|
||||
}
|
||||
|
||||
get(id: string, userId: number): Staged {
|
||||
const staged = this.files.get(id);
|
||||
if (!staged || staged.userId !== userId) fail(404, 'expired', 'That file has expired. Please add it again.');
|
||||
return staged;
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.files.delete(id);
|
||||
}
|
||||
|
||||
private sweep() {
|
||||
for (const [id, f] of this.files) if (Date.now() - f.createdAt > STAGE_TTL_MS) this.files.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
interface Row {
|
||||
date: string;
|
||||
postedDate: string | null;
|
||||
description: string;
|
||||
merchant: string;
|
||||
mentionsLast4: string | null;
|
||||
amountCents: number;
|
||||
bankCategory: string | null;
|
||||
bankType: string | null;
|
||||
balanceCents: number | null;
|
||||
}
|
||||
|
||||
interface Parsed {
|
||||
layout: ResolvedLayout | null;
|
||||
rows: Row[];
|
||||
skipped: number;
|
||||
header: string[] | null;
|
||||
sample: string[][];
|
||||
suggestedMap: ImportColumnMap | null;
|
||||
signature: string;
|
||||
last4Hint: string | null;
|
||||
openingBalance: { date: string; cents: number } | null;
|
||||
}
|
||||
|
||||
function fileNameLast4(fileName: string): string | null {
|
||||
return /^[A-Za-z]+(\d{4})[_-]/.exec(fileName)?.[1] ?? /(?:x|ending|acct|card)[-_ ]?(\d{4})\b/i.exec(fileName)?.[1] ?? null;
|
||||
}
|
||||
|
||||
/** The file name with its digits generalized, so "Chase1234_Activity_20261007.csv" matches next month's file. */
|
||||
const fileNamePattern = (fileName: string) => fileName.replace(/\d{5,}/g, '#').replace(/\(\d+\)/g, '');
|
||||
|
||||
function parseFile(ctx: AppContext, staged: Staged, mapping?: ImportColumnMap): Parsed {
|
||||
const table = readTable(staged.text);
|
||||
let layout = mapping ? null : detectBuiltIn(table);
|
||||
const headerRow = layout?.headerRow ?? findHeaderRow(table);
|
||||
const signature = headerSignature(table, headerRow);
|
||||
|
||||
if (!layout && headerRow >= 0) {
|
||||
const custom = mapping
|
||||
? { name: 'Your saved layout', config: JSON.stringify(mapping) }
|
||||
: ctx.db.select().from(importLayouts).where(eq(importLayouts.signature, signature)).get();
|
||||
if (custom) {
|
||||
layout = {
|
||||
key: 'id' in custom ? `custom-${custom.id}` : 'custom',
|
||||
name: custom.name,
|
||||
institution: null,
|
||||
kind: null,
|
||||
headerRow,
|
||||
map: JSON.parse(custom.config) as ImportColumnMap,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const header = headerRow >= 0 ? table[headerRow]! : null;
|
||||
const sample = table.slice(Math.max(0, headerRow + 1), Math.max(0, headerRow + 1) + 5);
|
||||
if (!layout) {
|
||||
return { layout: null, rows: [], skipped: 0, header, sample, suggestedMap: header ? guessMap(header) : null, signature, last4Hint: null, openingBalance: null };
|
||||
}
|
||||
|
||||
const m = layout.map;
|
||||
const rows: Row[] = [];
|
||||
let skipped = 0;
|
||||
let openingBalance: Parsed['openingBalance'] = null;
|
||||
let last4Hint = fileNameLast4(staged.fileName);
|
||||
|
||||
for (const cells of table.slice(layout.headerRow + 1)) {
|
||||
if (cells.every((c) => !c)) continue;
|
||||
const date = parseDate(cells[m.date], m.dateOrder);
|
||||
const description = (cells[m.description] ?? '').trim();
|
||||
const balanceCents = m.balance !== undefined ? parseCents(cells[m.balance]) : null;
|
||||
if (!date) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
// "Beginning balance as of ..." rows carry a balance but no amount.
|
||||
let amount: number | null;
|
||||
if (m.amount !== undefined) {
|
||||
const raw = parseCents(cells[m.amount]);
|
||||
amount = raw === null ? null : m.positiveIs === 'out' ? -raw : raw;
|
||||
} else {
|
||||
const debit = m.debit !== undefined ? parseCents(cells[m.debit]) : null;
|
||||
const credit = m.credit !== undefined ? parseCents(cells[m.credit]) : null;
|
||||
amount = debit ? -Math.abs(debit) : credit ? Math.abs(credit) : debit === 0 || credit === 0 ? 0 : null;
|
||||
}
|
||||
if (amount === null) {
|
||||
if (balanceCents !== null && /beginning balance/i.test(description)) openingBalance = { date, cents: balanceCents };
|
||||
else skipped++;
|
||||
continue;
|
||||
}
|
||||
if ('status' in m && m.status !== undefined && /pending/i.test(cells[m.status] ?? '')) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if ('cardNumber' in m && m.cardNumber !== undefined) last4Hint ??= /(\d{4})\s*$/.exec(cells[m.cardNumber] ?? '')?.[1] ?? null;
|
||||
const { merchant, mentionsLast4 } = cleanMerchant(description);
|
||||
rows.push({
|
||||
date,
|
||||
postedDate: m.postedDate !== undefined ? parseDate(cells[m.postedDate], m.dateOrder) : null,
|
||||
description,
|
||||
merchant,
|
||||
mentionsLast4,
|
||||
amountCents: amount,
|
||||
bankCategory: m.category !== undefined ? cells[m.category] || null : null,
|
||||
bankType: 'type' in m && m.type !== undefined ? cells[m.type] || null : null,
|
||||
balanceCents,
|
||||
});
|
||||
}
|
||||
return { layout, rows, skipped, header, sample, suggestedMap: null, signature, last4Hint, openingBalance };
|
||||
}
|
||||
|
||||
/** Which account a file belongs to, judged from fingerprints the person never has to manage. */
|
||||
function matchAccount(ctx: AppContext, parsed: Parsed, fileName: string): ImportPreview['account'] {
|
||||
const open = ctx.db.select().from(accounts).where(eq(accounts.status, 'open')).all();
|
||||
if (!open.length || !parsed.layout) return null;
|
||||
|
||||
if (parsed.last4Hint) {
|
||||
const byLast4 = open.filter((a) => a.last4 === parsed.last4Hint);
|
||||
if (byLast4.length === 1) return { id: byLast4[0]!.id, confidence: 'certain', reason: `The file is for the account ending ${parsed.last4Hint}.` };
|
||||
}
|
||||
|
||||
// Transactions already in an account are the strongest evidence.
|
||||
if (parsed.rows.length) {
|
||||
const dates = parsed.rows.map((r) => r.date).sort();
|
||||
let best: { account: AccountRow; hits: number } | null = null;
|
||||
for (const account of open) {
|
||||
const keys = new Set(
|
||||
ctx.db
|
||||
.select({ key: transactions.dedupeKey })
|
||||
.from(transactions)
|
||||
.where(and(eq(transactions.accountId, account.id), between(transactions.date, dates[0]!, dates.at(-1)!)))
|
||||
.all()
|
||||
.map((r) => r.key),
|
||||
);
|
||||
const hits = parsed.rows.filter((r) => keys.has(dedupeKey(account.id, r.date, r.amountCents, r.description))).length;
|
||||
if (hits && (!best || hits > best.hits)) best = { account, hits };
|
||||
}
|
||||
if (best && (best.hits >= 3 || best.hits >= parsed.rows.length * 0.3)) {
|
||||
return { id: best.account.id, confidence: 'certain', reason: `${best.hits} of these transactions are already in ${best.account.name}.` };
|
||||
}
|
||||
}
|
||||
|
||||
// A statement that starts exactly where an account's last one ended.
|
||||
if (parsed.openingBalance) {
|
||||
const continues = open.filter(
|
||||
(a) => a.balanceSource === 'statement' && a.balanceCents === parsed.openingBalance!.cents && a.layoutKey === parsed.layout!.key,
|
||||
);
|
||||
if (continues.length === 1) return { id: continues[0]!.id, confidence: 'certain', reason: `It picks up where ${continues[0]!.name}’s last statement ended.` };
|
||||
}
|
||||
|
||||
// Softer hints only count when no other account at the same bank could be meant.
|
||||
const institution = parsed.layout.institution?.toLowerCase();
|
||||
const sameBank = open.filter((a) => a.layoutKey === parsed.layout!.key || (institution && a.institution?.toLowerCase() === institution));
|
||||
const sameLayout = open.filter((a) => a.layoutKey === parsed.layout!.key);
|
||||
const samePattern = sameLayout.filter((a) => a.fileNamePattern === fileNamePattern(fileName));
|
||||
if (sameBank.length === 1 && samePattern.length === 1) return { id: samePattern[0]!.id, confidence: 'likely', reason: `Files like this have gone to ${samePattern[0]!.name} before.` };
|
||||
if (sameBank.length === 1 && sameLayout.length === 1) return { id: sameLayout[0]!.id, confidence: 'likely', reason: `${sameLayout[0]!.name} uses this bank’s layout.` };
|
||||
|
||||
const fresh = open.filter(
|
||||
(a) => !a.layoutKey && a.institution && parsed.layout!.institution && a.institution.toLowerCase() === parsed.layout!.institution.toLowerCase(),
|
||||
);
|
||||
if (fresh.length === 1) return { id: fresh[0]!.id, confidence: 'likely', reason: `It’s a ${parsed.layout.institution} file.` };
|
||||
return null;
|
||||
}
|
||||
|
||||
interface Planned {
|
||||
row: Row;
|
||||
key: string;
|
||||
status: 'new' | 'duplicate' | 'possible';
|
||||
possibleId: string | null;
|
||||
existingDate: string | null;
|
||||
}
|
||||
|
||||
/** Count-based duplicate check: import only copies beyond those already stored. */
|
||||
function planRows(ctx: AppContext, accountId: number, rows: Row[]): Planned[] {
|
||||
if (!rows.length) return [];
|
||||
const dates = rows.map((r) => r.date).sort();
|
||||
const from = dates[0]!;
|
||||
const to = dates.at(-1)!;
|
||||
const existing = ctx.db
|
||||
.select()
|
||||
.from(transactions)
|
||||
.where(and(eq(transactions.accountId, accountId), between(transactions.date, shiftDate(from, -3), shiftDate(to, 3))))
|
||||
.all();
|
||||
const existingCount = new Map<string, number>();
|
||||
for (const t of existing) existingCount.set(t.dedupeKey, (existingCount.get(t.dedupeKey) ?? 0) + 1);
|
||||
|
||||
const seen = new Map<string, number>();
|
||||
const planned: Planned[] = rows.map((row) => {
|
||||
const key = dedupeKey(accountId, row.date, row.amountCents, row.description);
|
||||
const n = (seen.get(key) ?? 0) + 1;
|
||||
seen.set(key, n);
|
||||
return { row, key, status: n <= (existingCount.get(key) ?? 0) ? 'duplicate' : 'new', possibleId: null, existingDate: null };
|
||||
});
|
||||
|
||||
// Near matches: same amount and merchant a day or three apart, inside this
|
||||
// file's dates, with no exact twin in the file. Usually a pending charge that
|
||||
// later posted with a new date.
|
||||
const fileKeys = new Set(planned.map((p) => p.key));
|
||||
const used = new Set<number>();
|
||||
for (const p of planned) {
|
||||
if (p.status !== 'new') continue;
|
||||
const near = existing.find(
|
||||
(t) =>
|
||||
!used.has(t.id) &&
|
||||
!fileKeys.has(t.dedupeKey) &&
|
||||
t.amountCents === p.row.amountCents &&
|
||||
t.merchant === p.row.merchant &&
|
||||
t.date !== p.row.date &&
|
||||
t.date >= from &&
|
||||
t.date <= to &&
|
||||
Math.abs(daysBetween(t.date, p.row.date)) <= 3,
|
||||
);
|
||||
if (near) {
|
||||
used.add(near.id);
|
||||
p.status = 'possible';
|
||||
p.possibleId = `${p.key}#${seen.get(p.key)}`;
|
||||
p.existingDate = near.date;
|
||||
}
|
||||
}
|
||||
return planned;
|
||||
}
|
||||
|
||||
function accountHint(parsed: Parsed): ImportPreview['accountHint'] {
|
||||
if (!parsed.layout) return null;
|
||||
const kind: AccountKind = parsed.layout.kind ?? 'checking';
|
||||
const institution = parsed.layout.institution;
|
||||
const last4 = parsed.last4Hint;
|
||||
const what = kind === 'credit_card' ? 'card' : kind;
|
||||
const name = [institution, what].filter(Boolean).join(' ') + (last4 ? ` ${last4}` : '');
|
||||
return { kind, institution, last4, name: name.charAt(0).toUpperCase() + name.slice(1) };
|
||||
}
|
||||
|
||||
export function previewImport(
|
||||
ctx: AppContext,
|
||||
stagingId: string,
|
||||
userId: number,
|
||||
options: { mapping?: ImportColumnMap; accountId?: number },
|
||||
): ImportPreview {
|
||||
const staged = ctx.staging.get(stagingId, userId);
|
||||
const parsed = parseFile(ctx, staged, options.mapping);
|
||||
const account = options.accountId
|
||||
? { id: options.accountId, confidence: 'certain' as const, reason: 'You chose this account.' }
|
||||
: matchAccount(ctx, parsed, staged.fileName);
|
||||
const planned = account ? planRows(ctx, account.id, parsed.rows) : parsed.rows.map((row) => ({ row, status: 'new' as const }));
|
||||
const dates = parsed.rows.map((r) => r.date).sort();
|
||||
const fresh = planned.filter((p) => p.status === 'new');
|
||||
|
||||
return {
|
||||
stagingId,
|
||||
fileName: staged.fileName,
|
||||
layout: parsed.layout ? { key: parsed.layout.key, name: parsed.layout.name } : null,
|
||||
sampleRows: parsed.layout ? [] : parsed.sample,
|
||||
headerRow: parsed.layout ? null : parsed.header,
|
||||
suggestedMap: parsed.suggestedMap,
|
||||
account,
|
||||
dateFrom: dates[0] ?? null,
|
||||
dateTo: dates.at(-1) ?? null,
|
||||
counts: {
|
||||
rows: parsed.rows.length,
|
||||
new: fresh.length,
|
||||
duplicates: planned.filter((p) => p.status === 'duplicate').length,
|
||||
possibleDuplicates: planned.filter((p) => p.status === 'possible').length,
|
||||
skipped: parsed.skipped,
|
||||
},
|
||||
newRows: fresh.slice(0, 8).map((p) => ({ date: p.row.date, merchant: p.row.merchant, amountCents: p.row.amountCents })),
|
||||
possibleDuplicates: planned
|
||||
.filter((p): p is Planned => p.status === 'possible')
|
||||
.map((p) => ({ key: p.possibleId!, date: p.row.date, merchant: p.row.merchant, amountCents: p.row.amountCents, existingDate: p.existingDate! })),
|
||||
accountHint: accountHint(parsed),
|
||||
};
|
||||
}
|
||||
|
||||
export function commitImport(
|
||||
ctx: AppContext,
|
||||
stagingId: string,
|
||||
userId: number,
|
||||
options: { accountId: number; mapping?: ImportColumnMap; includePossible?: string[] },
|
||||
): ImportSummary {
|
||||
const staged = ctx.staging.get(stagingId, userId);
|
||||
const account = ctx.db.select().from(accounts).where(eq(accounts.id, options.accountId)).get();
|
||||
if (!account || account.status !== 'open') fail(400, 'no_account', 'Please choose an open account for this file.');
|
||||
const parsed = parseFile(ctx, staged, options.mapping);
|
||||
if (!parsed.layout) fail(400, 'needs_mapping', 'Spendlight needs to know which column is which before importing this file.');
|
||||
if (!parsed.rows.length) fail(400, 'empty', 'No transactions were found in this file.');
|
||||
|
||||
const include = new Set(options.includePossible ?? []);
|
||||
const planned = planRows(ctx, account.id, parsed.rows).filter(
|
||||
(p) => p.status === 'new' || (p.status === 'possible' && include.has(p.possibleId!)),
|
||||
);
|
||||
const duplicateCount = parsed.rows.length - planned.length;
|
||||
const dates = parsed.rows.map((r) => r.date).sort();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const result = ctx.sqlite.transaction(() => {
|
||||
const suggester = new Suggester(ctx);
|
||||
const record = ctx.db
|
||||
.insert(imports)
|
||||
.values({
|
||||
accountId: account.id,
|
||||
fileName: staged.fileName,
|
||||
fileHash: staged.hash,
|
||||
layoutKey: parsed.layout!.key,
|
||||
dateFrom: dates[0] ?? null,
|
||||
dateTo: dates.at(-1) ?? null,
|
||||
rowCount: parsed.rows.length,
|
||||
addedCount: planned.length,
|
||||
duplicateCount,
|
||||
skippedCount: parsed.skipped,
|
||||
previousBalance: JSON.stringify({
|
||||
balanceCents: account.balanceCents,
|
||||
balanceAsOf: account.balanceAsOf,
|
||||
balanceSource: account.balanceSource,
|
||||
}),
|
||||
userId,
|
||||
createdAt: now,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
const ids: number[] = [];
|
||||
for (const { row, key } of planned) {
|
||||
const rule = suggester.rule(row.merchant, account.id);
|
||||
const merchant = rule?.renameTo || row.merchant;
|
||||
const s = suggester.suggest({ ...row, merchant, accountId: account.id });
|
||||
const inserted = ctx.db
|
||||
.insert(transactions)
|
||||
.values({
|
||||
accountId: account.id,
|
||||
importId: record.id,
|
||||
date: row.date,
|
||||
postedDate: row.postedDate,
|
||||
amountCents: row.amountCents,
|
||||
description: row.description,
|
||||
merchant,
|
||||
categoryId: s.categoryId,
|
||||
suggestedBy: s.source,
|
||||
needsReview: true,
|
||||
bankCategory: row.bankCategory,
|
||||
bankType: row.bankType,
|
||||
dedupeKey: key,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning({ id: transactions.id })
|
||||
.get();
|
||||
ids.push(inserted.id);
|
||||
}
|
||||
|
||||
const transferCount = pairTransfers(ctx, suggester, ids);
|
||||
ctx.db.update(imports).set({ transferCount }).where(eq(imports.id, record.id)).run();
|
||||
|
||||
// A layout from the mapping pop-up is saved, and remembered by its stored key.
|
||||
let layoutKey = parsed.layout!.key;
|
||||
if (options.mapping) {
|
||||
const existing = ctx.db.select().from(importLayouts).where(eq(importLayouts.signature, parsed.signature)).get();
|
||||
const layoutId = existing
|
||||
? (ctx.db.update(importLayouts).set({ config: JSON.stringify(options.mapping) }).where(eq(importLayouts.id, existing.id)).run(), existing.id)
|
||||
: ctx.db
|
||||
.insert(importLayouts)
|
||||
.values({ name: `${account.name} layout`, signature: parsed.signature, config: JSON.stringify(options.mapping), createdAt: now })
|
||||
.returning()
|
||||
.get().id;
|
||||
layoutKey = `custom-${layoutId}`;
|
||||
ctx.db.update(imports).set({ layoutKey }).where(eq(imports.id, record.id)).run();
|
||||
}
|
||||
|
||||
// Learn this account's fingerprints and, from a running balance, its balance.
|
||||
const updates: Partial<AccountRow> = {
|
||||
layoutKey,
|
||||
fileNamePattern: fileNamePattern(staged.fileName),
|
||||
updatedAt: now,
|
||||
};
|
||||
if (!account.last4 && parsed.last4Hint) updates.last4 = parsed.last4Hint;
|
||||
const withBalance = parsed.rows.filter((r) => r.balanceCents !== null);
|
||||
const last = withBalance.reduce<Row | null>((acc, r) => (!acc || r.date >= acc.date ? r : acc), null);
|
||||
if (last && (!account.balanceAsOf || last.date >= account.balanceAsOf || account.balanceSource !== 'statement')) {
|
||||
updates.balanceCents = last.balanceCents!;
|
||||
updates.balanceAsOf = last.date;
|
||||
updates.balanceSource = 'statement';
|
||||
}
|
||||
ctx.db.update(accounts).set(updates).where(eq(accounts.id, account.id)).run();
|
||||
return { record, transferCount };
|
||||
})();
|
||||
|
||||
// Keep the original file so a fixed importer can read it again later.
|
||||
try {
|
||||
const dir = join(ctx.config.dataDir, 'imports');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${result.record.id}-${staged.hash.slice(0, 12)}${extname(staged.fileName) || '.csv'}`), staged.text);
|
||||
} catch (error) {
|
||||
ctx.log.warn(`Could not keep a copy of ${staged.fileName}: ${(error as Error).message}`);
|
||||
}
|
||||
ctx.staging.delete(stagingId);
|
||||
logChange(ctx, userId, 'import', 'import', result.record.id, { file: staged.fileName, added: planned.length });
|
||||
return importSummary(ctx, result.record.id);
|
||||
}
|
||||
|
||||
export function importSummary(ctx: AppContext, id: number): ImportSummary {
|
||||
const row = ctx.db
|
||||
.select({ i: imports, accountName: accounts.name })
|
||||
.from(imports)
|
||||
.innerJoin(accounts, eq(accounts.id, imports.accountId))
|
||||
.where(eq(imports.id, id))
|
||||
.get();
|
||||
if (!row) fail(404, 'not_found', 'That import wasn’t found.');
|
||||
const toReview =
|
||||
ctx.db
|
||||
.select({ n: count() })
|
||||
.from(transactions)
|
||||
.where(and(eq(transactions.importId, id), eq(transactions.needsReview, true)))
|
||||
.get()?.n ?? 0;
|
||||
const { i } = row;
|
||||
return {
|
||||
id: i.id,
|
||||
accountId: i.accountId,
|
||||
accountName: row.accountName,
|
||||
fileName: i.fileName,
|
||||
layoutKey: i.layoutKey,
|
||||
dateFrom: i.dateFrom,
|
||||
dateTo: i.dateTo,
|
||||
addedCount: i.addedCount,
|
||||
duplicateCount: i.duplicateCount,
|
||||
skippedCount: i.skippedCount,
|
||||
transferCount: i.transferCount,
|
||||
toReview,
|
||||
status: i.status,
|
||||
createdAt: i.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function listImports(ctx: AppContext): ImportSummary[] {
|
||||
return ctx.db
|
||||
.select({ id: imports.id })
|
||||
.from(imports)
|
||||
.orderBy(desc(imports.createdAt))
|
||||
.limit(100)
|
||||
.all()
|
||||
.map((r) => importSummary(ctx, r.id));
|
||||
}
|
||||
|
||||
/** Remove everything one import added. Paired transfers in other accounts go back to Review. */
|
||||
export function undoImport(ctx: AppContext, id: number, userId: number): ImportSummary {
|
||||
const record = ctx.db.select().from(imports).where(eq(imports.id, id)).get();
|
||||
if (!record) fail(404, 'not_found', 'That import wasn’t found.');
|
||||
if (record.status === 'undone') fail(400, 'already_undone', 'That import was already undone.');
|
||||
const now = new Date().toISOString();
|
||||
|
||||
ctx.sqlite.transaction(() => {
|
||||
const added = ctx.db.select().from(transactions).where(eq(transactions.importId, id)).all();
|
||||
const addedIds = added.map((t) => t.id);
|
||||
const peers = added.map((t) => t.transferPeerId).filter((p): p is number => p !== null && !addedIds.includes(p));
|
||||
if (peers.length) {
|
||||
ctx.db.update(transactions).set({ transferPeerId: null, needsReview: true, updatedAt: now }).where(inArray(transactions.id, peers)).run();
|
||||
}
|
||||
if (addedIds.length) ctx.db.delete(transactions).where(inArray(transactions.id, addedIds)).run();
|
||||
|
||||
// Put the balance back if this import was the one that set it.
|
||||
const account = ctx.db.select().from(accounts).where(eq(accounts.id, record.accountId)).get();
|
||||
if (record.previousBalance && account?.balanceSource === 'statement' && account.balanceAsOf) {
|
||||
const fromThisFile = account.balanceAsOf >= (record.dateFrom ?? '') && account.balanceAsOf <= (record.dateTo ?? '');
|
||||
if (fromThisFile) {
|
||||
const prev = JSON.parse(record.previousBalance) as Pick<AccountRow, 'balanceCents' | 'balanceAsOf' | 'balanceSource'>;
|
||||
ctx.db.update(accounts).set({ ...prev, updatedAt: now }).where(eq(accounts.id, account.id)).run();
|
||||
}
|
||||
}
|
||||
ctx.db.update(imports).set({ status: 'undone', undoneAt: now }).where(eq(imports.id, id)).run();
|
||||
})();
|
||||
logChange(ctx, userId, 'undo_import', 'import', id);
|
||||
return importSummary(ctx, id);
|
||||
}
|
||||
|
||||
export { MAX_BYTES };
|
||||
Reference in New Issue
Block a user