BillTracker/services/userDbImportService.js

565 lines
20 KiB
JavaScript

'use strict';
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const Database = require('better-sqlite3');
const { getDb } = require('../db/database');
const MAX_SQLITE_BYTES = 50 * 1024 * 1024;
const SESSION_TTL_HOURS = 24;
const REQUIRED_TABLES = ['export_metadata', 'categories', 'bills', 'payments', 'monthly_bill_state'];
const VALID_BILLING_CYCLES = new Set(['monthly', 'quarterly', 'annually', 'irregular']);
const VALID_AUTODRAFT = new Set(['none', 'pending', 'assumed_paid', 'confirmed']);
function importError(status, message, code, details = []) {
const err = new Error(message);
err.status = status;
err.code = code;
err.details = details;
return err;
}
function sanitizeFilename(value) {
return value
? String(value).replace(/[^a-zA-Z0-9._\-\s]/g, '').trim().slice(0, 255) || null
: null;
}
function assertSqliteBuffer(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
throw importError(400, 'SQLite export file is required.', 'USER_DB_IMPORT_FILE_REQUIRED');
}
if (buffer.length > MAX_SQLITE_BYTES) {
throw importError(400, 'SQLite export file is too large. Maximum size is 50 MB.', 'USER_DB_IMPORT_FILE_TOO_LARGE');
}
if (buffer.length < 16 || buffer.subarray(0, 16).toString('binary') !== 'SQLite format 3\u0000') {
throw importError(400, 'File is not a valid SQLite database export.', 'USER_DB_IMPORT_INVALID_SQLITE');
}
}
function withTempSqlite(buffer, fn) {
const file = path.join(os.tmpdir(), `bill-tracker-user-import-${Date.now()}-${crypto.randomBytes(4).toString('hex')}.sqlite`);
fs.writeFileSync(file, buffer, { mode: 0o600 });
let src = null;
try {
src = new Database(file, { readonly: true, fileMustExist: true });
src.prepare('SELECT name FROM sqlite_master LIMIT 1').get();
return fn(src);
} finally {
try { src?.close(); } catch {}
try { fs.unlinkSync(file); } catch {}
}
}
function tableNames(db) {
return new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => r.name));
}
function tableColumns(db, table) {
return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name));
}
function selectKnown(db, table, columns) {
const cols = tableColumns(db, table);
const selected = columns.filter(c => cols.has(c));
if (!selected.length) return [];
return db.prepare(`SELECT ${selected.join(', ')} FROM ${table}`).all();
}
function toInt(value, fallback = null) {
if (value === undefined || value === null || value === '') return fallback;
const n = Number(value);
return Number.isInteger(n) ? n : fallback;
}
function toNumber(value, fallback = null) {
if (value === undefined || value === null || value === '') return fallback;
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function cleanText(value, max = 1000) {
if (value === undefined || value === null) return null;
const s = String(value).trim();
return s ? s.slice(0, max) : null;
}
function cleanDate(value) {
const s = cleanText(value, 32);
return /^\d{4}-\d{2}-\d{2}$/.test(s || '') ? s : null;
}
function normalizeName(value) {
return String(value || '').trim().toLowerCase();
}
function parseMetadata(src) {
const row = src.prepare("SELECT value FROM export_metadata WHERE key = 'metadata_json'").get();
if (!row?.value) {
throw importError(400, 'SQLite file is not a BillTracker user data export.', 'USER_DB_IMPORT_NOT_USER_EXPORT');
}
let metadata;
try {
metadata = JSON.parse(row.value);
} catch {
throw importError(400, 'SQLite export metadata could not be read.', 'USER_DB_IMPORT_BAD_METADATA');
}
if (metadata?.export_type !== 'user_data') {
throw importError(400, 'SQLite file is not a user data export created by this app.', 'USER_DB_IMPORT_NOT_USER_EXPORT');
}
return metadata;
}
function sanitizeCategory(row) {
const name = cleanText(row.name, 120);
if (!name) return null;
return {
old_id: toInt(row.id),
name,
created_at: cleanText(row.created_at, 32),
updated_at: cleanText(row.updated_at, 32),
};
}
function sanitizeBill(row) {
const name = cleanText(row.name, 160);
const dueDay = toInt(row.due_day);
if (!name || dueDay < 1 || dueDay > 31) return null;
const interestRate = toNumber(row.interest_rate, null);
return {
old_id: toInt(row.id),
name,
category_id: toInt(row.category_id),
due_day: dueDay,
override_due_date: cleanText(row.override_due_date, 32),
bucket: dueDay <= 14 ? '1st' : '15th',
expected_amount: Math.max(0, toNumber(row.expected_amount, 0) ?? 0),
interest_rate: interestRate == null || interestRate < 0 || interestRate > 100 ? null : interestRate,
billing_cycle: VALID_BILLING_CYCLES.has(row.billing_cycle) ? row.billing_cycle : 'monthly',
autopay_enabled: toInt(row.autopay_enabled, 0) ? 1 : 0,
autodraft_status: VALID_AUTODRAFT.has(row.autodraft_status) ? row.autodraft_status : 'none',
website: cleanText(row.website, 500),
username: cleanText(row.username, 255),
account_info: cleanText(row.account_info, 500),
has_2fa: toInt(row.has_2fa, 0) ? 1 : 0,
active: row.active === undefined ? 1 : (toInt(row.active, 1) ? 1 : 0),
notes: cleanText(row.notes, 2000),
created_at: cleanText(row.created_at, 32),
updated_at: cleanText(row.updated_at, 32),
};
}
function sanitizePayment(row, validBillIds) {
const billId = toInt(row.bill_id);
const amount = toNumber(row.amount);
const paidDate = cleanDate(row.paid_date);
if (!billId || !validBillIds.has(billId) || amount == null || amount < 0 || !paidDate) return null;
return {
old_id: toInt(row.id),
bill_id: billId,
amount,
paid_date: paidDate,
method: cleanText(row.method, 120),
notes: cleanText(row.notes, 2000),
created_at: cleanText(row.created_at, 32),
updated_at: cleanText(row.updated_at, 32),
};
}
function sanitizeMonthlyState(row, validBillIds) {
const billId = toInt(row.bill_id);
const year = toInt(row.year);
const month = toInt(row.month);
if (!billId || !validBillIds.has(billId) || year < 2000 || year > 2100 || month < 1 || month > 12) return null;
const actual = toNumber(row.actual_amount, null);
return {
old_id: toInt(row.id),
bill_id: billId,
year,
month,
actual_amount: actual == null || actual < 0 ? null : actual,
notes: cleanText(row.notes, 2000),
is_skipped: toInt(row.is_skipped, 0) ? 1 : 0,
created_at: cleanText(row.created_at, 32),
updated_at: cleanText(row.updated_at, 32),
};
}
function readExportData(src) {
const names = tableNames(src);
const missing = REQUIRED_TABLES.filter(t => !names.has(t));
if (missing.length) {
throw importError(400, 'SQLite file is not a supported BillTracker user export.', 'USER_DB_IMPORT_UNSUPPORTED_SCHEMA', missing.map(table => ({
table,
message: 'Required user export table is missing',
})));
}
const metadata = parseMetadata(src);
const categories = selectKnown(src, 'categories', ['id', 'name', 'created_at', 'updated_at'])
.map(sanitizeCategory).filter(Boolean);
const bills = selectKnown(src, 'bills', [
'id', 'name', 'category_id', 'due_day', 'override_due_date', 'bucket', 'expected_amount', 'interest_rate',
'billing_cycle', 'autopay_enabled', 'autodraft_status', 'website', 'username', 'account_info', 'has_2fa',
'active', 'notes', 'created_at', 'updated_at',
]).map(sanitizeBill).filter(Boolean);
const validBillIds = new Set(bills.map(b => b.old_id).filter(Boolean));
const payments = selectKnown(src, 'payments', ['id', 'bill_id', 'amount', 'paid_date', 'method', 'notes', 'created_at', 'updated_at'])
.map(row => sanitizePayment(row, validBillIds)).filter(Boolean);
const monthlyState = selectKnown(src, 'monthly_bill_state', ['id', 'bill_id', 'year', 'month', 'actual_amount', 'notes', 'is_skipped', 'created_at', 'updated_at'])
.map(row => sanitizeMonthlyState(row, validBillIds)).filter(Boolean);
const notes = names.has('notes')
? selectKnown(src, 'notes', ['type', 'bill_id', 'payment_id', 'monthly_state_id', 'year', 'month', 'notes'])
.map(n => ({ ...n, notes: cleanText(n.notes, 2000) })).filter(n => n.notes)
: [];
return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, notes };
}
function existingLookups(db, userId) {
const categories = db.prepare('SELECT id, name FROM categories WHERE user_id = ?').all(userId);
const bills = db.prepare('SELECT id, name, due_day FROM bills WHERE user_id = ?').all(userId);
return {
categoryByName: new Map(categories.map(c => [normalizeName(c.name), c])),
billByKey: new Map(bills.map(b => [`${normalizeName(b.name)}|${b.due_day}`, b])),
};
}
function buildPreview(userId, data, originalFilename) {
const db = getDb();
const lookups = existingLookups(db, userId);
const categoryPlan = data.categories.map(c => {
const existing = lookups.categoryByName.get(normalizeName(c.name));
return {
old_id: c.old_id,
name: c.name,
action: existing ? 'skip' : 'create',
existing_id: existing?.id ?? null,
reason: existing ? 'Category already exists for this user' : 'Category is missing for this user',
};
});
const categoryMap = new Map(categoryPlan.map(c => [c.old_id, c]));
const billPlan = data.bills.map(b => {
const existing = lookups.billByKey.get(`${normalizeName(b.name)}|${b.due_day}`);
const category = categoryMap.get(b.category_id);
return {
old_id: b.old_id,
name: b.name,
due_day: b.due_day,
category_name: category?.name ?? null,
action: existing ? 'skip' : 'create',
existing_id: existing?.id ?? null,
reason: existing ? 'Bill with same name and due day already exists for this user' : 'Bill is missing for this user',
};
});
const billPlanByOldId = new Map(billPlan.map(b => [b.old_id, b]));
const paymentPlan = data.payments.map(p => ({
old_id: p.old_id,
bill_old_id: p.bill_id,
action: billPlanByOldId.has(p.bill_id) ? 'create_or_skip_duplicate' : 'conflict',
reason: billPlanByOldId.has(p.bill_id) ? 'Will import if no duplicate payment exists' : 'Referenced bill is not present in export',
}));
const monthlyPlan = data.monthly_bill_state.map(m => ({
old_id: m.old_id,
bill_old_id: m.bill_id,
year: m.year,
month: m.month,
action: billPlanByOldId.has(m.bill_id) ? 'create_or_skip_duplicate' : 'conflict',
reason: billPlanByOldId.has(m.bill_id) ? 'Will import if monthly state is missing' : 'Referenced bill is not present in export',
}));
const summary = {
categories: {
total: data.categories.length,
create: categoryPlan.filter(x => x.action === 'create').length,
skip: categoryPlan.filter(x => x.action === 'skip').length,
conflict: 0,
},
bills: {
total: data.bills.length,
create: billPlan.filter(x => x.action === 'create').length,
skip: billPlan.filter(x => x.action === 'skip').length,
conflict: 0,
},
payments: {
total: data.payments.length,
create: data.payments.length,
skip: 0,
conflict: paymentPlan.filter(x => x.action === 'conflict').length,
},
monthly_bill_state: {
total: data.monthly_bill_state.length,
create: data.monthly_bill_state.length,
skip: 0,
conflict: monthlyPlan.filter(x => x.action === 'conflict').length,
},
notes: {
total: data.notes.length,
create: 0,
skip: data.notes.length,
conflict: 0,
},
};
const warnings = [
'Conflicts and duplicates are skipped by default.',
'This import does not restore users, passwords, sessions, admin settings, SMTP credentials, backups, or server paths.',
];
return {
import_type: 'user_db',
source_filename: originalFilename,
metadata: data.metadata,
counts: {
bills: data.bills.length,
categories: data.categories.length,
payments: data.payments.length,
monthly_bill_state: data.monthly_bill_state.length,
notes: data.notes.length,
},
summary,
proposed_actions: {
categories: categoryPlan.slice(0, 100),
bills: billPlan.slice(0, 100),
payments: paymentPlan.slice(0, 100),
monthly_bill_state: monthlyPlan.slice(0, 100),
},
warnings,
};
}
function saveSession(db, userId, sessionData) {
const id = crypto.randomUUID();
const now = new Date();
const expires = new Date(now.getTime() + SESSION_TTL_HOURS * 60 * 60 * 1000);
db.prepare(`
INSERT INTO import_sessions (id, user_id, created_at, expires_at, preview_json)
VALUES (?, ?, ?, ?, ?)
`).run(id, userId, now.toISOString(), expires.toISOString(), JSON.stringify(sessionData));
return id;
}
function loadSession(db, userId, importSessionId) {
const row = db.prepare(`
SELECT preview_json FROM import_sessions
WHERE id = ? AND user_id = ? AND expires_at > ?
`).get(importSessionId, userId, new Date().toISOString());
if (!row) {
throw importError(400, 'Import session is invalid or expired. Preview the SQLite export again.', 'USER_DB_IMPORT_SESSION_INVALID');
}
const data = JSON.parse(row.preview_json);
if (data.import_type !== 'user_db') {
throw importError(400, 'Import session is not a SQLite user export preview.', 'USER_DB_IMPORT_SESSION_TYPE');
}
return data;
}
async function previewUserDbImport(userId, buffer, options = {}) {
assertSqliteBuffer(buffer);
const originalFilename = sanitizeFilename(options.original_filename);
return withTempSqlite(buffer, (src) => {
const data = readExportData(src);
const preview = buildPreview(userId, data, originalFilename);
const db = getDb();
const importSessionId = saveSession(db, userId, {
import_type: 'user_db',
source_filename: originalFilename,
metadata: data.metadata,
data,
preview,
});
return {
import_session_id: importSessionId,
...preview,
};
});
}
function ensureCategory(db, userId, category, summary, details) {
const existing = db.prepare('SELECT id FROM categories WHERE user_id = ? AND LOWER(name) = LOWER(?)').get(userId, category.name);
if (existing) {
summary.rows_skipped++;
summary.rows_conflicted++;
details.categories.skipped++;
return existing.id;
}
const result = db.prepare('INSERT INTO categories (user_id, name) VALUES (?, ?)').run(userId, category.name);
summary.rows_created++;
details.categories.created++;
return result.lastInsertRowid;
}
function ensureBill(db, userId, bill, categoryId, summary, details) {
const existing = db.prepare('SELECT id FROM bills WHERE user_id = ? AND LOWER(name) = LOWER(?) AND due_day = ?')
.get(userId, bill.name, bill.due_day);
if (existing) {
summary.rows_skipped++;
summary.rows_conflicted++;
details.bills.skipped++;
return existing.id;
}
const result = db.prepare(`
INSERT INTO bills
(user_id, name, category_id, due_day, override_due_date, bucket, expected_amount, interest_rate,
billing_cycle, autopay_enabled, autodraft_status, website, username, account_info, has_2fa,
active, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
bill.name,
categoryId || null,
bill.due_day,
bill.override_due_date || null,
bill.bucket,
bill.expected_amount,
bill.interest_rate,
bill.billing_cycle,
bill.autopay_enabled,
bill.autodraft_status,
bill.website,
bill.username,
bill.account_info,
bill.has_2fa,
bill.active,
bill.notes,
);
summary.rows_created++;
details.bills.created++;
return result.lastInsertRowid;
}
function importPayment(db, targetBillId, payment, summary, details) {
const duplicate = db.prepare(`
SELECT p.id FROM payments p
WHERE p.bill_id = ? AND p.paid_date = ? AND p.amount = ? AND COALESCE(p.method, '') = COALESCE(?, '')
AND p.deleted_at IS NULL
`).get(targetBillId, payment.paid_date, payment.amount, payment.method);
if (duplicate) {
summary.rows_skipped++;
summary.rows_conflicted++;
details.payments.skipped++;
return;
}
db.prepare(`
INSERT INTO payments (bill_id, amount, paid_date, method, notes)
VALUES (?, ?, ?, ?, ?)
`).run(targetBillId, payment.amount, payment.paid_date, payment.method, payment.notes);
summary.rows_created++;
details.payments.created++;
}
function importMonthlyState(db, targetBillId, row, summary, details) {
const duplicate = db.prepare(`
SELECT id FROM monthly_bill_state WHERE bill_id = ? AND year = ? AND month = ?
`).get(targetBillId, row.year, row.month);
if (duplicate) {
summary.rows_skipped++;
summary.rows_conflicted++;
details.monthly_bill_state.skipped++;
return;
}
db.prepare(`
INSERT INTO monthly_bill_state (bill_id, year, month, actual_amount, notes, is_skipped)
VALUES (?, ?, ?, ?, ?, ?)
`).run(targetBillId, row.year, row.month, row.actual_amount, row.notes, row.is_skipped);
summary.rows_created++;
details.monthly_bill_state.created++;
}
async function applyUserDbImport(userId, importSessionId, options = {}) {
if (options.overwrite) {
throw importError(400, 'Overwrite is not supported for user SQLite imports. Existing data is skipped.', 'USER_DB_IMPORT_OVERWRITE_UNSUPPORTED');
}
const db = getDb();
const session = loadSession(db, userId, importSessionId);
const { data } = session;
const summary = {
import_type: 'user_db',
rows_created: 0,
rows_updated: 0,
rows_skipped: 0,
rows_conflicted: 0,
rows_errored: 0,
};
const details = {
categories: { created: 0, skipped: 0, errored: 0 },
bills: { created: 0, skipped: 0, errored: 0 },
payments: { created: 0, skipped: 0, errored: 0 },
monthly_bill_state: { created: 0, skipped: 0, errored: 0 },
notes: { created: 0, skipped: data.notes?.length || 0, errored: 0 },
};
summary.rows_skipped += details.notes.skipped;
const tx = db.transaction(() => {
const categoryIdMap = new Map();
for (const category of data.categories) {
categoryIdMap.set(category.old_id, ensureCategory(db, userId, category, summary, details));
}
const billIdMap = new Map();
for (const bill of data.bills) {
const targetCategoryId = bill.category_id ? categoryIdMap.get(bill.category_id) : null;
billIdMap.set(bill.old_id, ensureBill(db, userId, bill, targetCategoryId, summary, details));
}
for (const payment of data.payments) {
const targetBillId = billIdMap.get(payment.bill_id);
if (!targetBillId) {
summary.rows_skipped++;
summary.rows_conflicted++;
details.payments.skipped++;
continue;
}
importPayment(db, targetBillId, payment, summary, details);
}
for (const row of data.monthly_bill_state) {
const targetBillId = billIdMap.get(row.bill_id);
if (!targetBillId) {
summary.rows_skipped++;
summary.rows_conflicted++;
details.monthly_bill_state.skipped++;
continue;
}
importMonthlyState(db, targetBillId, row, summary, details);
}
db.prepare(`
INSERT INTO import_history
(user_id, imported_at, source_filename, file_type, sheet_name, rows_parsed,
rows_created, rows_updated, rows_skipped, rows_ambiguous, rows_errored,
options_json, summary_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
new Date().toISOString(),
session.source_filename || null,
'sqlite',
'User SQLite export',
data.categories.length + data.bills.length + data.payments.length + data.monthly_bill_state.length + (data.notes?.length || 0),
summary.rows_created,
summary.rows_updated,
summary.rows_skipped,
summary.rows_conflicted,
summary.rows_errored,
JSON.stringify({ overwrite: false }),
JSON.stringify({ ...summary, details }),
);
db.prepare('DELETE FROM import_sessions WHERE id = ? AND user_id = ?').run(importSessionId, userId);
});
tx();
return { ...summary, details };
}
module.exports = {
previewUserDbImport,
applyUserDbImport,
};